Compare commits

..

1 Commits

Author SHA1 Message Date
Bikram Choudhury
79b55ffd54 Initial MCP setup code 2026-01-15 17:24:46 +05:30
121 changed files with 13665 additions and 7591 deletions

View File

@@ -1 +1,20 @@
DATA_EXPLORER_ENDPOINT=https://localhost:1234/hostedExplorer.html
DATA_EXPLORER_ENDPOINT=https://localhost:1234/hostedExplorer.html
# Azure Test Environment Variables
# These are automatically set by the global-setup.ts script
# Azure resource group containing the test resources
DE_TEST_RESOURCE_GROUP=bchoudhury-e2e-testing
# Azure subscription ID for testing
DE_TEST_SUBSCRIPTION_ID=074d02eb-4d74-486a-b299-b262264d1536
# Prefix used for test resource names
DE_TEST_ACCOUNT_PREFIX=bchoudhury-e2e-
# Access token for NoSQL container copy operations
# This is generated automatically by the setup script
NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN=
# Optional: Set to true to enable debug logging during setup
DEBUG=false

View File

@@ -0,0 +1,71 @@
---
name: playwright-test-generator
description: 'Expert agent for generating robust Playwright tests for Cosmos DB. Supports DataExplorer and CopyJob flows. Required Inputs: <test-suite>, <test-name>, <test-file>, <seed-file>, <body>'
tools:
- azure-mcp/search
- playwright/browser_click
- playwright/browser_drag
- playwright/browser_evaluate
- playwright/browser_file_upload
- playwright/browser_handle_dialog
- playwright/browser_hover
- playwright/browser_navigate
- playwright/browser_press_key
- playwright/browser_select_option
- playwright/browser_snapshot
- playwright/browser_type
- playwright/browser_wait_for
- filesystem/read_file
- filesystem/write_file
- filesystem/list_directory
- filesystem/create_directory
model: Claude Sonnet 4
---
You are an expert Playwright Test Generator from given planner specs.
Your specialty is creating tests that match the project's high standards for frame navigation and FluentUI interaction. Your expertise includes functional testing using Playwright
# Pre-Generation Analysis
Before generating code, you MUST:
1. **Analyze Existing Specs:** Examine the provided `<seed-file>` and any referenced test files in the project to learn the specific coding standards, naming conventions, and import structures used.
2. **Identify the Flow Type:** Determine if the scenario is a **DataExplorer** flow or a **CopyJob** flow.
- **DataExplorer Flows:** Use `DataExplorer.open()` in the `beforeEach` or `beforeAll` setup logic.
- **CopyJob Flows:** Use `ContainerCopy.open()` in the `beforeEach` or `beforeAll` setup logic.
# Core Project Standards
- **Authentication:** Assume pre-flight authentication (az login/globalSetup) is complete. Do not generate login steps. The `global-setup` has been added into playwright.config.ts
- **Library Usage:** Prioritize common helper methods from `fx.ts`.
- **Page Hierarchy:**
- `frame`: Parent blade locator (used for overlays, dropdown lists, global portal buttons).
- `wrapper`: Child locator (used for the specific view content/form inputs).
- **Selector Strategy:** Use FluentUI-friendly locators: `getByRole` and `getByLabel`. Also prioritize `getByTestId` where available - check for `testIdAttribute` in playwright.config.ts as "data-test" attributes are configured as "data-testid".
# Step-by-Step Generation Workflow
1. **Plan Parsing:** Extract steps and verification logic from the planner spec.
2. **Setup:** Run `generator_setup_page` using the `<seed-file>`.
3. **Observation:** Use Playwright tools to execute steps. Pay close attention to which elements require the `frame` context vs. the `wrapper` context.
4. **Source Code Generation:**
- **File Naming:** Use fs-friendly names based on the scenario.
- **Structure:** Encapsulate in a `test.describe` matching the test plan group.
- **Code Style:** Match the patterns found in the analyzed existing files exactly.
- **Cleanup:** Ensure the test handles data cleanup to remain idempotent.
# Example Logic (Flow Detection)
```ts
// If CopyJob flow:
test.beforeEach(async ({ page }) => {
await ContainerCopy.open // Standardized entry point
});
(or)
test.beforeAll(async ({ page }) => {
await ContainerCopy.open // Standardized entry point
});
// If DataExplorer flow:
test.beforeEach(async ({ page }) => {
await DataExplorer.open // Standardized entry point
});
(or)
test.beforeAll(async ({ page }) => {
await DataExplorer.open // Standardized entry point
});

View File

@@ -0,0 +1,61 @@
---
name: playwright-test-healer
description: Use this agent when you need to debug and fix failing Playwright tests
tools:
- search
- edit
- playwright-test/browser_console_messages
- playwright-test/browser_evaluate
- playwright-test/browser_generate_locator
- playwright-test/browser_network_requests
- playwright-test/browser_snapshot
- playwright-test/test_debug
- playwright-test/test_list
- playwright-test/test_run
model: Claude Sonnet 4
mcp-servers:
playwright-test:
type: stdio
command: npx
args:
- playwright
- run-test-mcp-server
tools:
- "*"
---
You are the Playwright Test Healer, an expert test automation engineer specializing in debugging and resolving Playwright test failures. Your mission is to systematically identify, diagnose, and fix broken Playwright tests using a methodical approach.
Your workflow:
1. **Initial Execution**: Run all tests using `test_run` tool to identify failing tests
2. **Debug failed tests**: For each failing test run `test_debug`.
3. **Error Investigation**: When the test pauses on errors, use available Playwright MCP tools to:
- Examine the error details
- Capture page snapshot to understand the context
- Analyze selectors, timing issues, or assertion failures
4. **Root Cause Analysis**: Determine the underlying cause of the failure by examining:
- Element selectors that may have changed
- Timing and synchronization issues
- Data dependencies or test environment problems
- Application changes that broke test assumptions
5. **Code Remediation**: Edit the test code to address identified issues, focusing on:
- Updating selectors to match current application state
- Fixing assertions and expected values
- Improving test reliability and maintainability
- For inherently dynamic data, utilize regular expressions to produce resilient locators
6. **Verification**: Restart the test after each fix to validate the changes
7. **Iteration**: Repeat the investigation and fixing process until the test passes cleanly
Key principles:
- Be systematic and thorough in your debugging approach
- Document your findings and reasoning for each fix
- Prefer robust, maintainable solutions over quick hacks
- Use Playwright best practices for reliable test automation
- If multiple errors exist, fix them one at a time and retest
- Provide clear explanations of what was broken and how you fixed it
- You will continue this process until the test runs successfully without any failures or errors.
- If the error persists and you have high level of confidence that the test is correct, mark this test as test.fixme()
so that it is skipped during the execution. Add a comment before the failing step explaining what is happening instead
of the expected behavior.
- Do not ask user questions, you are not interactive tool, do the most reasonable thing possible to pass the test.
- Never wait for networkidle or use other discouraged or deprecated apis

View File

@@ -0,0 +1,78 @@
---
name: playwright-test-planner
description: Use this agent when you need to create comprehensive test plan for a web application or website
tools:
- search
- playwright-test/browser_click
- playwright-test/browser_close
- playwright-test/browser_console_messages
- playwright-test/browser_drag
- playwright-test/browser_evaluate
- playwright-test/browser_file_upload
- playwright-test/browser_handle_dialog
- playwright-test/browser_hover
- playwright-test/browser_navigate
- playwright-test/browser_navigate_back
- playwright-test/browser_network_requests
- playwright-test/browser_press_key
- playwright-test/browser_select_option
- playwright-test/browser_snapshot
- playwright-test/browser_take_screenshot
- playwright-test/browser_type
- playwright-test/browser_wait_for
- playwright-test/planner_setup_page
- playwright-test/planner_save_plan
model: Claude Sonnet 4
mcp-servers:
playwright-test:
type: stdio
command: npx
args:
- playwright
- run-test-mcp-server
tools:
- "*"
---
You are an expert web test planner with extensive experience in quality assurance, user experience testing, and test scenario design. Your expertise includes functional testing, edge case identification, and comprehensive test coverage planning.
You will:
1. **Navigate and Explore**
- Invoke the `planner_setup_page` tool once to set up page before using any other tools
- Explore the browser snapshot
- Do not take screenshots unless absolutely necessary
- Use `browser_*` tools to navigate and discover interface
- Thoroughly explore the interface, identifying all interactive elements, forms, navigation paths, and functionality
2. **Analyze User Flows**
- Map out the primary user journeys and identify critical paths through the application
- Consider different user types and their typical behaviors
3. **Design Comprehensive Scenarios**
Create detailed test scenarios that cover:
- Happy path scenarios (normal user behavior)
- Edge cases and boundary conditions
- Error handling and validation
4. **Structure Test Plans**
Each scenario must include:
- Clear, descriptive title
- Detailed step-by-step instructions
- Expected outcomes where appropriate
- Assumptions about starting state (always assume blank/fresh state)
- Success criteria and failure conditions
5. **Create Documentation**
Submit your test plan using `planner_save_plan` tool.
**Quality Standards**:
- Write steps that are specific enough for any tester to follow
- Include negative testing scenarios
- Ensure scenarios are independent and can be run in any order
**Output Format**: Always save the complete test plan as a markdown file with clear headings, numbered steps, and
professional formatting suitable for sharing with development and QA teams.

View File

@@ -6,8 +6,8 @@ on:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
schedule:
# Once every day at 7 AM PST
- cron: "0 13 * * *"
# Once every two hours
- cron: "0 */2 * * *"
permissions:
id-token: write

View File

@@ -0,0 +1,34 @@
name: "Copilot Setup Steps"
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
pull_request:
paths:
- .github/workflows/copilot-setup-steps.yml
jobs:
copilot-setup-steps:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
# Customize this step as needed
- name: Build application
run: npx run build

2
.gitignore vendored
View File

@@ -17,8 +17,8 @@ Contracts/*
failure.png
screenshots/*
GettingStarted-ignore*.ipynb
src/Localization/Keys.generated.ts
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/

23
.vscode/mcp.json vendored Normal file
View File

@@ -0,0 +1,23 @@
{
"servers": {
"playwright": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@playwright/mcp@latest"],
"env": {
"ALLOWED_DIRECTORIES": "C:/Users/bchoudhury/MyProjects/cosmos-explorer"
}
},
"filesystem": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem@latest",
"C:/Users/bchoudhury/MyProjects/cosmos-explorer/test",
"C:/Users/bchoudhury/MyProjects/cosmos-explorer/src"
]
}
},
"inputs": []
}

14308
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"description": "Cosmos Explorer",
"main": "index.js",
"dependencies": {
"@azure/arm-cosmosdb": "16.4.0",
"@azure/arm-cosmosdb": "9.1.0",
"@azure/cosmos": "4.7.0",
"@azure/cosmos-language-service": "0.0.5",
"@azure/identity": "4.5.0",
@@ -14,12 +14,12 @@
"@fluentui/react": "8.119.0",
"@fluentui/react-components": "9.54.2",
"@jupyterlab/services": "6.0.2",
"@jupyterlab/terminal": "3.6.8",
"@jupyterlab/terminal": "3.0.3",
"@microsoft/applicationinsights-web": "2.6.1",
"@nteract/commutable": "7.5.1",
"@nteract/connected-components": "6.8.2",
"@nteract/core": "15.1.9",
"@nteract/data-explorer": "8.2.12",
"@nteract/data-explorer": "8.0.3",
"@nteract/directory-listing": "2.0.6",
"@nteract/dropdown-menu": "1.0.1",
"@nteract/editor": "10.1.12",
@@ -31,7 +31,7 @@
"@nteract/monaco-editor": "3.2.2",
"@nteract/octicons": "2.0.0",
"@nteract/outputs": "3.0.9",
"@nteract/presentational-components": "3.4.12",
"@nteract/presentational-components": "3.0.7",
"@nteract/stateful-components": "1.7.0",
"@nteract/styles": "2.0.2",
"@nteract/transform-geojson": "5.1.8",
@@ -39,26 +39,25 @@
"@nteract/transform-plotly": "6.1.6",
"@nteract/transform-vdom": "4.0.11",
"@nteract/transform-vega": "7.0.6",
"@octokit/request": "8.4.1",
"@octokit/rest": "17.9.2",
"@phosphor/widgets": "1.9.3",
"@testing-library/jest-dom": "6.4.6",
"@types/lodash": "4.14.171",
"@types/mkdirp": "1.0.1",
"@types/node-fetch": "2.6.13",
"@types/node-fetch": "2.5.7",
"@xmldom/xmldom": "0.7.13",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
"allotment": "1.20.2",
"applicationinsights": "1.8.0",
"bootstrap": "3.4.1",
"canvas": "3.2.1",
"canvas": "2.11.2",
"clean-webpack-plugin": "4.0.0",
"clipboard-copy": "4.0.1",
"copy-webpack-plugin": "11.0.0",
"crossroads": "0.12.2",
"css-element-queries": "1.1.1",
"d3": "7.9.0",
"d3": "7.8.5",
"datatables.net-colreorder-dt": "1.7.0",
"datatables.net-dt": "1.13.8",
"date-fns": "1.29.0",
@@ -71,8 +70,7 @@
"html2canvas": "1.0.0-rc.5",
"i18next": "23.11.5",
"i18next-browser-languagedetector": "6.0.1",
"i18next-http-backend": "3.0.2",
"i18next-resources-to-backend": "1.2.1",
"i18next-http-backend": "1.0.23",
"iframe-resizer-react": "1.1.0",
"immer": "9.0.6",
"immutable": "4.0.0-rc.12",
@@ -81,15 +79,12 @@
"jquery-typeahead": "2.11.1",
"jquery-ui-dist": "1.13.2",
"knockout": "3.5.1",
"lodash": "4.17.23",
"lodash-es": "4.17.23",
"min-document": "2.19.1",
"loader-utils": "2.0.3",
"mkdirp": "1.0.4",
"monaco-editor": "0.44.0",
"ms": "2.1.3",
"nanoid": "3.3.8",
"p-retry": "6.2.1",
"patch-package": "8.0.1",
"patch-package": "8.0.0",
"plotly.js-cartesian-dist-min": "1.52.3",
"post-robot": "10.0.42",
"q": "1.5.1",
@@ -108,6 +103,7 @@
"react-youtube": "9.0.1",
"reflect-metadata": "0.1.13",
"rx-jupyter": "5.5.12",
"sanitize-html": "2.3.3",
"shell-quote": "1.7.3",
"styled-components": "5.0.1",
"swr": "0.4.0",
@@ -117,28 +113,14 @@
"utility-types": "3.10.0",
"uuid": "9.0.0",
"web-vitals": "4.2.4",
"ws": "8.17.1",
"zustand": "3.5.0"
},
"overrides": {
"d3-color": "3.1.0",
"cross-spawn": "7.0.6",
"less-vars-loader": {
"json5": "1.0.2"
},
"trim": "0.0.3",
"@octokit/plugin-paginate-rest": "9.2.2",
"@octokit/request-error": "5.1.1",
"@octokit/request": "8.4.1",
"prismjs": "1.30.0",
"sanitize-html": "2.17.0"
},
"devDependencies": {
"@babel/core": "7.29.0",
"@babel/core": "7.24.7",
"@babel/preset-env": "7.24.7",
"@babel/preset-react": "7.24.7",
"@babel/preset-typescript": "7.24.7",
"@playwright/test": "1.55.1",
"@playwright/test": "1.57.0",
"@testing-library/react": "11.2.3",
"@types/applicationinsights-js": "1.0.7",
"@types/codemirror": "0.0.56",
@@ -152,7 +134,7 @@
"@types/hasher": "0.0.31",
"@types/jest": "29.5.12",
"@types/jquery": "3.5.29",
"@types/node": "18.19.0",
"@types/node": "12.11.1",
"@types/post-robot": "10.0.1",
"@types/q": "1.5.1",
"@types/react": "17.0.44",
@@ -163,7 +145,7 @@
"@types/react-window": "1.8.8",
"@types/sanitize-html": "1.27.2",
"@types/sinon": "2.3.3",
"@types/styled-components": "5.1.32",
"@types/styled-components": "5.1.1",
"@types/underscore": "1.7.36",
"@types/youtube-player": "5.5.6",
"@typescript-eslint/eslint-plugin": "6.7.4",
@@ -171,7 +153,6 @@
"@webpack-cli/serve": "2.0.5",
"babel-jest": "29.7.0",
"babel-loader": "8.1.0",
"brace-expansion": "1.1.12",
"buffer": "5.1.0",
"case-sensitive-paths-webpack-plugin": "2.4.0",
"create-file-webpack": "1.0.2",
@@ -189,7 +170,6 @@
"html-inline-css-webpack-plugin": "1.11.2",
"html-loader": "5.0.0",
"html-webpack-plugin": "5.5.3",
"i18next-resources-for-ts": "2.0.0",
"jest": "29.7.0",
"jest-canvas-mock": "2.5.2",
"jest-circus": "29.7.0",
@@ -197,8 +177,7 @@
"jest-html-loader": "1.0.0",
"jest-react-hooks-shallow": "1.5.1",
"jest-trx-results-processor": "3.0.2",
"js-yaml": "3.14.2",
"less": "4.5.1",
"less": "3.8.1",
"less-loader": "11.1.3",
"less-vars-loader": "1.1.0",
"mini-css-extract-plugin": "2.1.0",
@@ -216,17 +195,14 @@
"typedoc": "0.26.2",
"typescript": "4.9.5",
"url-loader": "4.1.1",
"values-to-keys": "1.1.0",
"wait-on": "9.0.3",
"webpack": "5.104.1",
"webpack-bundle-analyzer": "5.2.0",
"wait-on": "4.0.2",
"webpack": "5.88.2",
"webpack-bundle-analyzer": "4.9.1",
"webpack-cli": "5.1.4",
"webpack-dev-server": "5.2.3",
"ws": "8.17.1"
"webpack-dev-server": "4.15.2"
},
"scripts": {
"postinstall": "patch-package && npm run generate:i18n-keys",
"prestart": "npm run generate:i18n-keys",
"postinstall": "patch-package",
"start": "webpack serve --mode development",
"dev": "echo \"WARNING: npm run dev has been deprecated\" && npm run build",
"build:dataExplorer:ci": "npm run build:ci",
@@ -237,7 +213,11 @@
"copyToConsumers": "node copyToConsumers",
"test": "rimraf coverage && jest",
"test:debug": "jest --runInBand",
"test:e2e": "jest -c ./jest.config.playwright.js --detectOpenHandles",
"test:e2e": "npx playwright test",
"test:e2e:setup:verify": "npx playwright test test/setup-verification.spec.ts",
"test:e2e:setup": "node setup-tests.js",
"test:e2e:headed": "npx playwright test --headed",
"test:e2e:ui": "npx playwright test --ui",
"test:file": "jest --coverage=false",
"watch": "npm run start",
"wait-for-server": "wait-on -t 240000 -i 5000 -v https-get://0.0.0.0:1234/",
@@ -253,7 +233,6 @@
"strict:find": "node ./strict-null-checks/find.js",
"strict:add": "node ./strict-null-checks/auto-add.js",
"compile:fullStrict": "tsc -p ./tsconfig.json --strictNullChecks",
"generate:i18n-keys": "node utils/generateI18nKeys.mjs",
"generateARMClients": "npx ts-node utils/armClientGenerator/generator.ts"
},
"repository": {

View File

@@ -6,15 +6,17 @@ export default defineConfig({
testDir: "test",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 3 : 0,
retries: process.env.CI ? 3 : 2,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? "blob" : "html",
timeout: 10 * 60 * 1000,
globalSetup: require.resolve('./test/global-setup.ts'),
use: {
trace: "retain-on-failure",
video: "retain-on-failure",
screenshot: "on",
testIdAttribute: "data-test",
// storageState: './test/../playwright/.auth/user.json',
contextOptions: {
ignoreHTTPSErrors: true,
},

View File

@@ -8,10 +8,9 @@
"name": "cosmos-explorer-preview",
"version": "1.0.0",
"dependencies": {
"body-parser": "^2.2.2",
"express": "^5.2.1",
"follow-redirects": "^1.15.6",
"http-proxy-middleware": "^3.0.5",
"body-parser": "^1.20.3",
"express": "^4.21.2",
"http-proxy-middleware": "^3.0.3",
"node": "^20.19.5",
"node-fetch": "^2.6.1",
"path-to-regexp": "^0.1.12"
@@ -19,7 +18,8 @@
},
"node_modules/@types/http-proxy": {
"version": "1.17.16",
"license": "MIT",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.16.tgz",
"integrity": "sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==",
"dependencies": {
"@types/node": "*"
}
@@ -29,40 +29,50 @@
"license": "MIT"
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"version": "1.3.8",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/body-parser": {
"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==",
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
"bytes": "3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
"qs": "6.13.0",
"raw-body": "2.5.2",
"type-is": "~1.6.18",
"unpipe": "1.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/body-parser/node_modules/debug": {
"version": "2.6.9",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/body-parser/node_modules/ms": {
"version": "2.0.0",
"license": "MIT"
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -73,7 +83,8 @@
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"license": "MIT",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
@@ -84,7 +95,8 @@
},
"node_modules/call-bound": {
"version": "1.0.4",
"license": "MIT",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
@@ -97,15 +109,13 @@
}
},
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"engines": {
"node": ">=18"
"version": "0.5.4",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
@@ -117,22 +127,20 @@
},
"node_modules/cookie": {
"version": "0.7.1",
"license": "MIT",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"engines": {
"node": ">=6.6.0"
}
"version": "1.0.6",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"license": "MIT",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": {
"ms": "^2.1.3"
},
@@ -152,9 +160,18 @@
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"license": "MIT",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
@@ -178,21 +195,24 @@
},
"node_modules/es-define-property": {
"version": "1.0.1",
"license": "MIT",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"license": "MIT",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"license": "MIT",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dependencies": {
"es-errors": "^1.3.0"
},
@@ -218,77 +238,104 @@
"license": "MIT"
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"dependencies": {
"accepts": "^2.0.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",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "1.20.3",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
"cookie": "0.7.1",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "1.3.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "0.1.12",
"proxy-addr": "~2.0.7",
"qs": "6.13.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "0.19.0",
"serve-static": "1.16.2",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 18"
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/express/node_modules/array-flatten": {
"version": "1.1.1",
"license": "MIT"
},
"node_modules/express/node_modules/debug": {
"version": "2.6.9",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/express/node_modules/ms": {
"version": "2.0.0",
"license": "MIT"
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"statuses": "2.0.1",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
"node": ">= 0.8"
}
},
"node_modules/finalhandler/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/finalhandler/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/follow-redirects": {
"version": "1.15.6",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
"integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
"version": "1.15.3",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
@@ -306,23 +353,25 @@
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"engines": {
"node": ">= 0.8"
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"license": "MIT",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"license": "MIT",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
@@ -344,7 +393,8 @@
},
"node_modules/get-proto": {
"version": "1.0.1",
"license": "MIT",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
@@ -355,7 +405,8 @@
},
"node_modules/gopd": {
"version": "1.2.0",
"license": "MIT",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"engines": {
"node": ">= 0.4"
},
@@ -365,7 +416,8 @@
},
"node_modules/has-symbols": {
"version": "1.1.0",
"license": "MIT",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"engines": {
"node": ">= 0.4"
},
@@ -375,7 +427,8 @@
},
"node_modules/hasown": {
"version": "2.0.2",
"license": "MIT",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dependencies": {
"function-bind": "^1.1.2"
},
@@ -384,22 +437,17 @@
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"version": "2.0.0",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
"depd": "2.0.0",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"toidentifier": "1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/http-proxy": {
@@ -432,7 +480,8 @@
},
"node_modules/http-proxy-middleware/node_modules/braces": {
"version": "3.0.3",
"license": "MIT",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dependencies": {
"fill-range": "^7.1.1"
},
@@ -442,7 +491,8 @@
},
"node_modules/http-proxy-middleware/node_modules/fill-range": {
"version": "7.1.1",
"license": "MIT",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dependencies": {
"to-regex-range": "^5.0.1"
},
@@ -452,14 +502,16 @@
},
"node_modules/http-proxy-middleware/node_modules/is-number": {
"version": "7.0.0",
"license": "MIT",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/http-proxy-middleware/node_modules/micromatch": {
"version": "4.0.8",
"license": "MIT",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
@@ -470,7 +522,8 @@
},
"node_modules/http-proxy-middleware/node_modules/to-regex-range": {
"version": "5.0.1",
"license": "MIT",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dependencies": {
"is-number": "^7.0.0"
},
@@ -479,18 +532,14 @@
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
@@ -521,58 +570,62 @@
"node": ">=0.10.0"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"license": "MIT",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"version": "0.3.0",
"license": "MIT",
"engines": {
"node": ">= 0.8"
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"engines": {
"node": ">=18"
},
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"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==",
"version": "1.52.0",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"version": "2.1.35",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
"mime-db": "1.52.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
"node": ">= 0.6"
}
},
"node_modules/ms": {
@@ -580,27 +633,32 @@
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"version": "0.6.3",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/node": {
"version": "20.19.5",
"resolved": "https://registry.npmjs.org/node/-/node-20.19.5.tgz",
"integrity": "sha512-9fJOHEP8AVrwpbhlUxnbudW8IbkseQVxl4yNQyI/rDfP+gNwKEmfPtBc/Luyf677i5Y0HKIBHiApiB9S9vvxKw==",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"node-bin-setup": "^1.0.0"
},
"bin": {
"node": "bin/node.exe"
"node": "bin/node"
},
"engines": {
"npm": ">=5.0.0"
}
},
"node_modules/node-bin-setup": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/node-bin-setup/-/node-bin-setup-1.1.4.tgz",
"integrity": "sha512-vWNHOne0ZUavArqPP5LJta50+S8R261Fr5SvGul37HbEDcowvLjwdvd0ZeSr0r2lTSrPxl6okq9QUw8BFGiAxA=="
},
"node_modules/node-fetch": {
"version": "2.6.7",
"license": "MIT",
@@ -619,13 +677,10 @@
}
}
},
"node_modules/node/node_modules/node-bin-setup": {
"version": "1.1.4",
"license": "ISC"
},
"node_modules/object-inspect": {
"version": "1.13.4",
"license": "MIT",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"engines": {
"node": ">= 0.4"
},
@@ -643,14 +698,6 @@
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -661,7 +708,8 @@
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"license": "MIT"
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
},
"node_modules/picomatch": {
"version": "2.3.1",
@@ -692,10 +740,11 @@
}
},
"node_modules/qs": {
"version": "6.14.1",
"license": "BSD-3-Clause",
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"dependencies": {
"side-channel": "^1.1.0"
"side-channel": "^1.0.6"
},
"engines": {
"node": ">=0.6"
@@ -713,46 +762,40 @@
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.10"
"node": ">= 0.8"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"license": "MIT"
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/router/node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
"node_modules/safe-buffer": {
"version": "5.2.1",
"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/safer-buffer": {
"version": "2.1.2",
@@ -760,46 +803,61 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
"dependencies": {
"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.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~1.0.2",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "2.4.1",
"range-parser": "~1.2.1",
"statuses": "2.0.1"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/send/node_modules/debug/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/send/node_modules/encodeurl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "0.19.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
@@ -808,7 +866,8 @@
},
"node_modules/side-channel": {
"version": "1.1.0",
"license": "MIT",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
@@ -825,7 +884,8 @@
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"license": "MIT",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
@@ -839,7 +899,8 @@
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"license": "MIT",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
@@ -855,7 +916,8 @@
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"license": "MIT",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
@@ -871,9 +933,8 @@
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"version": "2.0.1",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
@@ -890,13 +951,11 @@
"license": "MIT"
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"version": "1.6.18",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
@@ -909,6 +968,13 @@
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"license": "MIT",
@@ -927,11 +993,6 @@
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
}
}
}

View File

@@ -11,10 +11,9 @@
"keywords": [],
"author": "Microsoft Corporation",
"dependencies": {
"body-parser": "^2.2.2",
"express": "^5.2.1",
"follow-redirects": "^1.15.6",
"http-proxy-middleware": "^3.0.5",
"body-parser": "^1.20.3",
"express": "^4.21.2",
"http-proxy-middleware": "^3.0.3",
"node": "^20.19.5",
"node-fetch": "^2.6.1",
"path-to-regexp": "^0.1.12"

48
setup-tests copy.js Normal file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env node
/**
* Manual setup script to run the pre-flight Azure authentication and configuration
* This can be used independently of Playwright for setting up the testing environment
*/
const { exec } = require('child_process');
const { promisify } = require('util');
const path = require('path');
const execAsync = promisify(exec);
async function runManualSetup() {
console.log('🚀 Starting manual Azure Cosmos DB Explorer test setup...');
try {
// Import the global setup function and run it
const globalSetup = require('./test/global-setup.js');
// Create a minimal config object that matches what Playwright would pass
const mockConfig = {
configFile: path.join(__dirname, 'playwright.config.ts'),
rootDir: __dirname,
testDir: path.join(__dirname, 'test'),
projects: []
};
await globalSetup(mockConfig);
console.log('✅ Manual setup completed successfully!');
console.log('\nYou can now run your Playwright tests with:');
console.log(' npm run test:e2e');
console.log(' or');
console.log(' npx playwright test');
} catch (error) {
console.error('❌ Manual setup failed:', error.message);
process.exit(1);
}
}
// Only run if this script is executed directly
if (require.main === module) {
runManualSetup();
}
module.exports = { runManualSetup };

48
setup-tests.ts Normal file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env node
/**
* Manual setup script to run the pre-flight Azure authentication and configuration
* This can be used independently of Playwright for setting up the testing environment
*/
async function runManualSetup(): Promise<void> {
console.log('🚀 Starting manual Azure Cosmos DB Explorer test setup...');
try {
// Dynamically import the Playwright global setup
const globalSetupModule = await import('./test/global-setup');
const globalSetup = globalSetupModule.default;
if (typeof globalSetup !== 'function') {
throw new Error('global-setup.ts does not export a default function');
}
// Minimal mock config similar to what Playwright provides
/* const mockConfig: FullConfig = {
configFile: path.join(__dirname, 'playwright.config.ts'),
rootDir: __dirname,
testDir: path.join(__dirname, 'test'),
projects: []
}; */
await globalSetup();
console.log('✅ Manual setup completed successfully!');
console.log('\nYou can now run your Playwright tests with:');
console.log(' npm run test:e2e');
console.log(' or');
console.log(' npx playwright test');
} catch (error: any) {
console.error('❌ Manual setup failed:', error.message);
process.exit(1);
}
}
// Only run if executed directly (not imported)
if (require.main === module) {
void runManualSetup();
}
export { runManualSetup };

View File

@@ -1,11 +0,0 @@
import "i18next";
import Resources from "../Localization/en/Resources.json";
declare module "i18next" {
interface CustomTypeOptions {
defaultNS: "Resources";
resources: {
Resources: typeof Resources;
};
}
}

View File

@@ -1,7 +1,5 @@
import { MessageTypes } from "../Contracts/ExplorerContracts";
import { SubscriptionType } from "../Contracts/SubscriptionType";
import { isExpectedError } from "../Metrics/ErrorClassification";
import { scenarioMonitor } from "../Metrics/ScenarioMonitor";
import { userContext } from "../UserContext";
import { ARMError } from "../Utils/arm/request";
import { logConsoleError } from "../Utils/NotificationConsoleUtils";
@@ -9,36 +7,19 @@ import { HttpStatusCodes } from "./Constants";
import { logError } from "./Logger";
import { sendMessage } from "./MessageHandler";
export interface HandleErrorOptions {
/** Optional redacted error to use for telemetry logging instead of the original error */
redactedError?: string | ARMError | Error;
}
export const handleError = (
error: string | ARMError | Error,
area: string,
consoleErrorPrefix?: string,
options?: HandleErrorOptions,
): void => {
export const handleError = (error: string | ARMError | Error, area: string, consoleErrorPrefix?: string): void => {
const errorMessage = getErrorMessage(error);
const errorCode = error instanceof ARMError ? error.code : undefined;
// logs error to data explorer console (always shows original, non-redacted message)
// logs error to data explorer console
const consoleErrorMessage = consoleErrorPrefix ? `${consoleErrorPrefix}:\n ${errorMessage}` : errorMessage;
logConsoleError(consoleErrorMessage);
// logs error to both app insight and kusto (use redacted message if provided)
const telemetryErrorMessage = options?.redactedError ? getErrorMessage(options.redactedError) : errorMessage;
logError(telemetryErrorMessage, area, errorCode);
// logs error to both app insight and kusto
logError(errorMessage, area, errorCode);
// checks for errors caused by firewall and sends them to portal to handle
sendNotificationForError(errorMessage, errorCode);
// Mark expected failures for health metrics (auth, firewall, permissions, etc.)
// This ensures timeouts with expected failures emit healthy instead of unhealthy
if (isExpectedError(error)) {
scenarioMonitor.markExpectedFailure();
}
};
export const getErrorMessage = (error: string | Error = ""): string => {

View File

@@ -38,7 +38,7 @@ export function queryIterator(databaseId: string, collection: Collection, query:
let continuationToken: string;
return {
fetchNext: () => {
return queryDocuments(databaseId, collection, false, query, continuationToken).then((response) => {
return queryDocuments(databaseId, collection, false, query).then((response) => {
continuationToken = response.continuationToken;
const headers: { [key: string]: string | number } = {};
response.headers.forEach((value, key) => {

View File

@@ -44,8 +44,7 @@ export const deleteDocuments = async (
documentIds: DocumentId[],
abortSignal: AbortSignal,
): Promise<IBulkDeleteResult[]> => {
const totalCount = documentIds.length;
const clearMessage = logConsoleProgress(`Deleting ${totalCount} ${getEntityName(true)}`);
const clearMessage = logConsoleProgress(`Deleting ${documentIds.length} ${getEntityName(true)}`);
try {
const v2Container = await client().database(collection.databaseId).container(collection.id());
@@ -84,7 +83,11 @@ export const deleteDocuments = async (
const flatAllResult = Array.prototype.concat.apply([], allResult);
return flatAllResult;
} catch (error) {
handleError(error, "DeleteDocuments", `Error while deleting ${totalCount} ${getEntityName(totalCount > 1)}`);
handleError(
error,
"DeleteDocuments",
`Error while deleting ${documentIds.length} ${getEntityName(documentIds.length > 1)}`,
);
throw error;
} finally {
clearMessage();

View File

@@ -1,171 +0,0 @@
import { redactSyntaxErrorMessage } from "./queryDocumentsPage";
/* Typical error to redact looks like this (the message property contains a JSON string with nested structure):
{
"message": "{\"code\":\"BadRequest\",\"message\":\"{\\\"errors\\\":[{\\\"severity\\\":\\\"Error\\\",\\\"location\\\":{\\\"start\\\":0,\\\"end\\\":5},\\\"code\\\":\\\"SC1001\\\",\\\"message\\\":\\\"Syntax error, incorrect syntax near 'Crazy'.\\\"}]}\\r\\nActivityId: d5424e10-51bd-46f7-9aec-7b40bed36f17, Windows/10.0.20348 cosmos-netstandard-sdk/3.18.0\"}"
}
*/
// Helper to create the nested error structure that matches what the SDK returns
const createNestedError = (
errors: Array<{ severity?: string; location?: { start: number; end: number }; code: string; message: string }>,
activityId: string = "test-activity-id",
): { message: string } => {
const innerErrorsJson = JSON.stringify({ errors });
const innerMessage = `${innerErrorsJson}\r\n${activityId}`;
const outerJson = JSON.stringify({ code: "BadRequest", message: innerMessage });
return { message: outerJson };
};
// Helper to parse the redacted result
const parseRedactedResult = (result: { message: string }) => {
const outerParsed = JSON.parse(result.message);
const [innerErrorsJson, activityIdPart] = outerParsed.message.split("\r\n");
const innerErrors = JSON.parse(innerErrorsJson);
return { outerParsed, innerErrors, activityIdPart };
};
describe("redactSyntaxErrorMessage", () => {
it("should redact SC1001 error message", () => {
const error = createNestedError(
[
{
severity: "Error",
location: { start: 0, end: 5 },
code: "SC1001",
message: "Syntax error, incorrect syntax near 'Crazy'.",
},
],
"ActivityId: d5424e10-51bd-46f7-9aec-7b40bed36f17",
);
const result = redactSyntaxErrorMessage(error) as { message: string };
const { outerParsed, innerErrors, activityIdPart } = parseRedactedResult(result);
expect(outerParsed.code).toBe("BadRequest");
expect(innerErrors.errors[0].message).toBe("__REDACTED__");
expect(activityIdPart).toContain("ActivityId: d5424e10-51bd-46f7-9aec-7b40bed36f17");
});
it("should redact SC2001 error message", () => {
const error = createNestedError(
[
{
severity: "Error",
location: { start: 0, end: 10 },
code: "SC2001",
message: "Some sensitive syntax error message.",
},
],
"ActivityId: abc123",
);
const result = redactSyntaxErrorMessage(error) as { message: string };
const { outerParsed, innerErrors, activityIdPart } = parseRedactedResult(result);
expect(outerParsed.code).toBe("BadRequest");
expect(innerErrors.errors[0].message).toBe("__REDACTED__");
expect(activityIdPart).toContain("ActivityId: abc123");
});
it("should redact multiple errors with SC1001 and SC2001 codes", () => {
const error = createNestedError(
[
{ severity: "Error", code: "SC1001", message: "First error" },
{ severity: "Error", code: "SC2001", message: "Second error" },
],
"ActivityId: xyz",
);
const result = redactSyntaxErrorMessage(error) as { message: string };
const { innerErrors } = parseRedactedResult(result);
expect(innerErrors.errors[0].message).toBe("__REDACTED__");
expect(innerErrors.errors[1].message).toBe("__REDACTED__");
});
it("should not redact errors with other codes", () => {
const error = createNestedError(
[{ severity: "Error", code: "SC9999", message: "This should not be redacted." }],
"ActivityId: test123",
);
const result = redactSyntaxErrorMessage(error);
expect(result).toBe(error); // Should return original error unchanged
});
it("should not modify non-BadRequest errors", () => {
const innerMessage = JSON.stringify({ errors: [{ code: "SC1001", message: "Should not be redacted" }] });
const error = {
message: JSON.stringify({ code: "NotFound", message: innerMessage }),
};
const result = redactSyntaxErrorMessage(error);
expect(result).toBe(error);
});
it("should handle errors without message property", () => {
const error = { code: "BadRequest" };
const result = redactSyntaxErrorMessage(error);
expect(result).toBe(error);
});
it("should handle non-object errors", () => {
const stringError = "Simple string error";
const nullError: null = null;
const undefinedError: undefined = undefined;
expect(redactSyntaxErrorMessage(stringError)).toBe(stringError);
expect(redactSyntaxErrorMessage(nullError)).toBe(nullError);
expect(redactSyntaxErrorMessage(undefinedError)).toBe(undefinedError);
});
it("should handle malformed JSON in message", () => {
const error = {
message: "not valid json",
};
const result = redactSyntaxErrorMessage(error);
expect(result).toBe(error);
});
it("should handle message without ActivityId suffix", () => {
const innerErrorsJson = JSON.stringify({
errors: [{ severity: "Error", code: "SC1001", message: "Syntax error near something." }],
});
const error = {
message: JSON.stringify({ code: "BadRequest", message: innerErrorsJson + "\r\n" }),
};
const result = redactSyntaxErrorMessage(error) as { message: string };
const { innerErrors } = parseRedactedResult(result);
expect(innerErrors.errors[0].message).toBe("__REDACTED__");
});
it("should preserve other error properties", () => {
const baseError = createNestedError([{ code: "SC1001", message: "Error" }], "ActivityId: test");
const error = {
...baseError,
statusCode: 400,
additionalInfo: "extra data",
};
const result = redactSyntaxErrorMessage(error) as {
message: string;
statusCode: number;
additionalInfo: string;
};
expect(result.statusCode).toBe(400);
expect(result.additionalInfo).toBe("extra data");
const { innerErrors } = parseRedactedResult(result);
expect(innerErrors.errors[0].message).toBe("__REDACTED__");
});
});

View File

@@ -4,51 +4,6 @@ import { getEntityName } from "../DocumentUtility";
import { handleError } from "../ErrorHandlingUtils";
import { MinimalQueryIterator, nextPage } from "../IteratorUtilities";
// Redact sensitive information from BadRequest errors with specific codes
export const redactSyntaxErrorMessage = (error: unknown): unknown => {
const codesToRedact = ["SC1001", "SC2001", "SC1010"];
try {
// Handle error objects with a message property
if (error && typeof error === "object" && "message" in error) {
const errorObj = error as { code?: string; message?: string };
if (typeof errorObj.message === "string") {
// Parse the inner JSON from the message
const innerJson = JSON.parse(errorObj.message);
if (innerJson.code === "BadRequest" && typeof innerJson.message === "string") {
const [innerErrorsJson, activityIdPart] = innerJson.message.split("\r\n");
const innerErrorsObj = JSON.parse(innerErrorsJson);
if (Array.isArray(innerErrorsObj.errors)) {
let modified = false;
innerErrorsObj.errors = innerErrorsObj.errors.map((err: { code?: string; message?: string }) => {
if (err.code && codesToRedact.includes(err.code)) {
modified = true;
return { ...err, message: "__REDACTED__" };
}
return err;
});
if (modified) {
// Reconstruct the message with the redacted content
const redactedMessage = JSON.stringify(innerErrorsObj) + `\r\n${activityIdPart}`;
const redactedError = {
...error,
message: JSON.stringify({ ...innerJson, message: redactedMessage }),
body: undefined as unknown, // Clear body to avoid sensitive data
};
return redactedError;
}
}
}
}
}
} catch {
// If parsing fails, return the original error
}
return error;
};
export const queryDocumentsPage = async (
resourceName: string,
documentsIterator: MinimalQueryIterator,
@@ -63,12 +18,7 @@ export const queryDocumentsPage = async (
logConsoleInfo(`Successfully fetched ${itemCount} ${entityName} for container ${resourceName}`);
return result;
} catch (error) {
// Redact sensitive information for telemetry while showing original in console
const redactedError = redactSyntaxErrorMessage(error);
handleError(error, "QueryDocumentsPage", `Failed to query ${entityName} for container ${resourceName}`, {
redactedError: redactedError as Error,
});
handleError(error, "QueryDocumentsPage", `Failed to query ${entityName} for container ${resourceName}`);
throw error;
} finally {
clearMessage();

View File

@@ -77,12 +77,6 @@ let configContext: Readonly<ConfigContext> = {
`^https:\\/\\/.*\\.fabric\\.microsoft\\.com$`,
`^https:\\/\\/.*\\.powerbi\\.com$`,
`^https:\\/\\/dataexplorer-preview\\.azurewebsites\\.net$`,
`^https:\\/\\/explorer\\.cosmos\\.sovcloud-api\\.fr$`,
`^https:\\/\\/portal\\.sovcloud-azure\\.fr$`,
`^https:\\/\\/explorer\\.cosmos\\.sovcloud-api\\.de$`,
`^https:\\/\\/portal\\.sovcloud-azure\\.de$`,
`^https:\\/\\/explorer\\.cosmos\\.sovcloud-api\\.sg$`,
`^https:\\/\\/portal\\.sovcloud-azure\\.sg$`,
], // Webpack injects this at build time
gitSha: process.env.GIT_SHA,
hostedExplorerURL: "https://cosmos.azure.com/",

View File

@@ -46,10 +46,6 @@ export type DataExploreMessageV3 =
params: {
updateType: "created" | "deleted" | "settings";
};
}
| {
type: FabricMessageTypes.RestoreContainer;
params: [];
};
export interface GetCosmosTokenMessageOptions {
verb: "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace";

View File

@@ -275,7 +275,8 @@ export interface DataMaskingPolicy {
startPosition: number;
length: number;
}>;
excludedPaths?: string[];
excludedPaths: string[];
isPolicyEnabled: boolean;
}
export interface MaterializedView {

View File

@@ -184,10 +184,5 @@ export default {
Skipped: "Cancelled",
Cancelled: "Cancelled",
},
dialog: {
heading: "",
confirmButtonText: "Confirm",
cancelButtonText: "Cancel",
},
},
};

View File

@@ -395,14 +395,6 @@ describe("CopyJobUtils", () => {
expect(result).toBe(false);
});
it("should return false for different completion percentage", () => {
const jobs1 = [createMockJob("job1", "Running")];
const jobs2 = [{ ...createMockJob("job1", "Running"), CompletionPercentage: 75 }];
const result = CopyJobUtils.isEqual(jobs1, jobs2);
expect(result).toBe(false);
});
it("should return true for empty arrays", () => {
const result = CopyJobUtils.isEqual([], []);
expect(result).toBe(true);

View File

@@ -142,7 +142,7 @@ export function isEqual(prevJobs: CopyJobType[], newJobs: CopyJobType[]): boolea
if (!newJob) {
return false;
}
return prevJob.Status === newJob.Status && prevJob.CompletionPercentage === newJob.CompletionPercentage;
return prevJob.Status === newJob.Status;
});
}

View File

@@ -1,4 +1,3 @@
/* eslint-disable jest/no-conditional-expect */
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
@@ -6,20 +5,6 @@ import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../E
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
import CopyJobActionMenu from "./CopyJobActionMenu";
const mockShowOkCancelModalDialog = jest.fn();
const mockCloseDialog = jest.fn();
const mockOpenDialog = jest.fn();
jest.mock("../../../Controls/Dialog", () => ({
useDialog: {
getState: () => ({
showOkCancelModalDialog: mockShowOkCancelModalDialog,
closeDialog: mockCloseDialog,
openDialog: mockOpenDialog,
}),
},
}));
jest.mock("../../ContainerCopyMessages", () => ({
__esModule: true,
default: {
@@ -33,11 +18,6 @@ jest.mock("../../ContainerCopyMessages", () => ({
cancel: "Cancel",
complete: "Complete",
},
dialog: {
heading: "Confirm Action",
confirmButtonText: "Confirm",
cancelButtonText: "Cancel",
},
},
},
}));
@@ -70,9 +50,6 @@ describe("CopyJobActionMenu", () => {
beforeEach(() => {
jest.clearAllMocks();
mockShowOkCancelModalDialog.mockClear();
mockCloseDialog.mockClear();
mockOpenDialog.mockClear();
});
describe("Component Rendering", () => {
@@ -289,29 +266,7 @@ describe("CopyJobActionMenu", () => {
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.pause, expect.any(Function));
});
it("should show confirmation dialog when cancel action is clicked", () => {
const job = createMockJob({ Name: "Test Job", Status: CopyJobStatusType.InProgress });
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
const actionButton = screen.getByRole("button", { name: "Actions" });
fireEvent.click(actionButton);
const cancelButton = screen.getByText("Cancel");
fireEvent.click(cancelButton);
expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith(
"Confirm Action",
null,
"Confirm",
expect.any(Function),
"Cancel",
null,
expect.any(Object), // dialogBody content
);
});
it("should call handleClick when dialog is confirmed for cancel action", () => {
it("should call handleClick when cancel action is clicked", () => {
const job = createMockJob({ Status: CopyJobStatusType.InProgress });
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
@@ -322,9 +277,6 @@ describe("CopyJobActionMenu", () => {
const cancelButton = screen.getByText("Cancel");
fireEvent.click(cancelButton);
const [, , , onOkCallback] = mockShowOkCancelModalDialog.mock.calls[0];
onOkCallback();
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.cancel, expect.any(Function));
});
@@ -342,33 +294,7 @@ describe("CopyJobActionMenu", () => {
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.resume, expect.any(Function));
});
it("should show confirmation dialog when complete action is clicked", () => {
const job = createMockJob({
Name: "Test Online Job",
Status: CopyJobStatusType.InProgress,
Mode: CopyJobMigrationType.Online,
});
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
const actionButton = screen.getByRole("button", { name: "Actions" });
fireEvent.click(actionButton);
const completeButton = screen.getByText("Complete");
fireEvent.click(completeButton);
expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith(
"Confirm Action",
null,
"Confirm",
expect.any(Function),
"Cancel",
null,
expect.any(Object), // dialogBody content
);
});
it("should call handleClick when dialog is confirmed for complete action", () => {
it("should call handleClick when complete action is clicked", () => {
const job = createMockJob({
Status: CopyJobStatusType.InProgress,
Mode: CopyJobMigrationType.Online,
@@ -382,87 +308,10 @@ describe("CopyJobActionMenu", () => {
const completeButton = screen.getByText("Complete");
fireEvent.click(completeButton);
const [, , , onOkCallback] = mockShowOkCancelModalDialog.mock.calls[0];
onOkCallback();
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.complete, expect.any(Function));
});
});
describe("Dialog Body Content", () => {
it("should pass correct dialog body content for cancel action", () => {
const job = createMockJob({ Name: "MyTestJob", Status: CopyJobStatusType.InProgress });
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
const actionButton = screen.getByRole("button", { name: "Actions" });
fireEvent.click(actionButton);
const cancelButton = screen.getByText("Cancel");
fireEvent.click(cancelButton);
expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith(
"Confirm Action",
null,
"Confirm",
expect.any(Function),
"Cancel",
null,
expect.objectContaining({
props: expect.objectContaining({
tokens: expect.any(Object),
children: expect.any(Array),
}),
}),
);
});
it("should pass correct dialog body content for complete action", () => {
const job = createMockJob({
Name: "OnlineTestJob",
Status: CopyJobStatusType.InProgress,
Mode: CopyJobMigrationType.Online,
});
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
const actionButton = screen.getByRole("button", { name: "Actions" });
fireEvent.click(actionButton);
const completeButton = screen.getByText("Complete");
fireEvent.click(completeButton);
expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith(
"Confirm Action",
null,
"Confirm",
expect.any(Function),
"Cancel",
null,
expect.objectContaining({
props: expect.objectContaining({
tokens: expect.any(Object),
children: expect.any(Array),
}),
}),
);
});
it("should not show dialog body for actions without confirmation", () => {
const job = createMockJob({ Status: CopyJobStatusType.InProgress });
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
const actionButton = screen.getByRole("button", { name: "Actions" });
fireEvent.click(actionButton);
const pauseButton = screen.getByText("Pause");
fireEvent.click(pauseButton);
expect(mockShowOkCancelModalDialog).not.toHaveBeenCalled();
});
});
describe("Disabled States During Updates", () => {
const TestComponentWrapper: React.FC<{
job: CopyJobType;
@@ -490,13 +339,8 @@ describe("CopyJobActionMenu", () => {
const pauseButton = screen.getByText("Pause");
fireEvent.click(pauseButton);
fireEvent.click(actionButton);
const pauseButtonAfterClick = screen.getByText("Pause").closest("button");
const pauseButtonAfterClick = screen.getByText("Pause");
expect(pauseButtonAfterClick).toBeInTheDocument();
expect(pauseButtonAfterClick).toHaveAttribute("aria-disabled", "true");
const cancelButtonAfterClick = screen.getByText("Cancel").closest("button");
expect(cancelButtonAfterClick).toHaveAttribute("aria-disabled", "true");
});
it("should not disable actions for different jobs when one is updating", () => {
@@ -516,7 +360,23 @@ describe("CopyJobActionMenu", () => {
expect(screen.getByText("Cancel")).toBeInTheDocument();
});
it("should disable complete action when job is being updated", () => {
it("should properly handle multiple action types being disabled for the same job", () => {
const job = createMockJob({ Status: CopyJobStatusType.InProgress });
render(<TestComponentWrapper job={job} />);
const actionButton = screen.getByRole("button", { name: "Actions" });
fireEvent.click(actionButton);
fireEvent.click(screen.getByText("Pause"));
fireEvent.click(actionButton);
fireEvent.click(screen.getByText("Cancel"));
fireEvent.click(actionButton);
expect(screen.getByText("Pause")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
});
it("should handle complete action disabled state for online jobs", () => {
const job = createMockJob({
Status: CopyJobStatusType.InProgress,
Mode: CopyJobMigrationType.Online,
@@ -530,34 +390,8 @@ describe("CopyJobActionMenu", () => {
const completeButton = screen.getByText("Complete");
fireEvent.click(completeButton);
// Simulate dialog confirmation to trigger state update
const [, , , onOkCallback] = mockShowOkCancelModalDialog.mock.calls[0];
onOkCallback();
fireEvent.click(actionButton);
const completeButtonAfterClick = screen.getByText("Complete").closest("button");
expect(completeButtonAfterClick).toBeInTheDocument();
expect(completeButtonAfterClick).toHaveAttribute("aria-disabled", "true");
});
it("should disable complete action when any other action is being performed", () => {
const job = createMockJob({
Status: CopyJobStatusType.InProgress,
Mode: CopyJobMigrationType.Online,
});
render(<TestComponentWrapper job={job} />);
const actionButton = screen.getByRole("button", { name: "Actions" });
fireEvent.click(actionButton);
const pauseButton = screen.getByText("Pause");
fireEvent.click(pauseButton);
fireEvent.click(actionButton);
const completeButtonAfterClick = screen.getByText("Complete").closest("button");
expect(completeButtonAfterClick).toBeInTheDocument();
expect(completeButtonAfterClick).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText("Complete")).toBeInTheDocument();
});
});
@@ -628,7 +462,6 @@ describe("CopyJobActionMenu", () => {
expect(actionButton).toHaveAttribute("aria-label", "Actions");
expect(actionButton).toHaveAttribute("title", "Actions");
expect(actionButton).toHaveAttribute("role", "button");
const moreIcon = actionButton.querySelector('[data-icon-name="More"]');
expect(moreIcon || actionButton).toBeInTheDocument();
@@ -775,129 +608,4 @@ describe("CopyJobActionMenu", () => {
}).not.toThrow();
});
});
describe("Complete Coverage Tests", () => {
it("should handle all possible dialog scenarios", () => {
const dialogTests = [
{ action: CopyJobActions.cancel, status: CopyJobStatusType.InProgress, shouldShowDialog: true },
{
action: CopyJobActions.complete,
status: CopyJobStatusType.InProgress,
mode: CopyJobMigrationType.Online,
shouldShowDialog: true,
},
{ action: CopyJobActions.pause, status: CopyJobStatusType.InProgress, shouldShowDialog: false },
{ action: CopyJobActions.resume, status: CopyJobStatusType.Paused, shouldShowDialog: false },
];
dialogTests.forEach(({ action, status, mode = CopyJobMigrationType.Offline, shouldShowDialog }, index) => {
jest.clearAllMocks();
const job = createMockJob({ Status: status, Mode: mode, Name: `DialogTestJob${index}` });
const { unmount } = render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
const actionButton = screen.getByRole("button", { name: "Actions" });
fireEvent.click(actionButton);
const actionText = action.charAt(0).toUpperCase() + action.slice(1);
if (screen.queryByText(actionText)) {
fireEvent.click(screen.getByText(actionText));
if (shouldShowDialog) {
expect(mockShowOkCancelModalDialog).toHaveBeenCalled();
} else {
expect(mockShowOkCancelModalDialog).not.toHaveBeenCalled();
expect(mockHandleClick).toHaveBeenCalled();
}
}
unmount();
});
});
it("should verify component handles state updates correctly", () => {
const job = createMockJob({ Status: CopyJobStatusType.InProgress });
const stateUpdater = jest.fn();
const testHandleClick: HandleJobActionClickType = (job, action, setUpdatingJobAction) => {
setUpdatingJobAction({ jobName: job.Name, action });
stateUpdater(job.Name, action);
};
render(<CopyJobActionMenu job={job} handleClick={testHandleClick} />);
const actionButton = screen.getByRole("button", { name: "Actions" });
fireEvent.click(actionButton);
const pauseButton = screen.getByText("Pause");
fireEvent.click(pauseButton);
expect(stateUpdater).toHaveBeenCalledWith(job.Name, CopyJobActions.pause);
});
});
describe("Full Integration Coverage", () => {
it("should test complete workflow for cancel action with dialog", () => {
const job = createMockJob({ Name: "Integration Test Job", Status: CopyJobStatusType.InProgress });
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
const actionButton = screen.getByRole("button", { name: "Actions" });
expect(actionButton).toHaveAttribute("data-test", "CopyJobActionMenu/Button:Integration Test Job");
fireEvent.click(actionButton);
const cancelButton = screen.getByText("Cancel");
fireEvent.click(cancelButton);
expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith(
"Confirm Action", // title
null, // subText
"Confirm", // confirmLabel
expect.any(Function), // onOk
"Cancel", // cancelLabel
null, // onCancel
expect.any(Object), // contentHtml (dialogBody)
);
const onOkCallback = mockShowOkCancelModalDialog.mock.calls[0][3];
onOkCallback();
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.cancel, expect.any(Function));
});
it("should test complete workflow for complete action with dialog", () => {
const job = createMockJob({
Name: "Online Integration Job",
Status: CopyJobStatusType.Running,
Mode: CopyJobMigrationType.Online,
});
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
const actionButton = screen.getByRole("button", { name: "Actions" });
fireEvent.click(actionButton);
const completeButton = screen.getByText("Complete");
fireEvent.click(completeButton);
expect(mockShowOkCancelModalDialog).toHaveBeenCalled();
const dialogContent = mockShowOkCancelModalDialog.mock.calls[0][6];
expect(dialogContent).toBeTruthy();
const onOkCallback = mockShowOkCancelModalDialog.mock.calls[0][3];
onOkCallback();
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.complete, expect.any(Function));
});
it("should maintain proper component lifecycle", () => {
const job = createMockJob({ Status: CopyJobStatusType.InProgress });
const { rerender, unmount } = render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
rerender(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
expect(screen.getByRole("button", { name: "Actions" })).toBeInTheDocument();
expect(() => unmount()).not.toThrow();
});
});
});

View File

@@ -1,6 +1,5 @@
import { DirectionalHint, IconButton, IContextualMenuProps, Stack } from "@fluentui/react";
import { IconButton, IContextualMenuProps } from "@fluentui/react";
import React from "react";
import { useDialog } from "../../../Controls/Dialog";
import ContainerCopyMessages from "../../ContainerCopyMessages";
import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../Enums/CopyJobEnums";
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
@@ -10,28 +9,6 @@ interface CopyJobActionMenuProps {
handleClick: HandleJobActionClickType;
}
const dialogBody = {
[CopyJobActions.cancel]: (jobName: string) => (
<Stack tokens={{ childrenGap: 10 }}>
<Stack.Item>
You are about to cancel <b>{jobName}</b> copy job.
</Stack.Item>
<Stack.Item>Cancelling will stop the job immediately.</Stack.Item>
</Stack>
),
[CopyJobActions.complete]: (jobName: string) => (
<Stack tokens={{ childrenGap: 10 }}>
<Stack.Item>
You are about to complete <b>{jobName}</b> copy job.
</Stack.Item>
<Stack.Item>
Once completed, continuous data copy will stop after any pending documents are processed. To maintain data
integrity, we recommend stopping updates to the source container before completing the job.
</Stack.Item>
</Stack>
),
};
const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick }) => {
const [updatingJobAction, setUpdatingJobAction] = React.useState<{ jobName: string; action: string } | null>(null);
if (
@@ -45,22 +22,9 @@ const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick
return null;
}
const showActionConfirmationDialog = (job: CopyJobType, action: CopyJobActions): void => {
useDialog
.getState()
.showOkCancelModalDialog(
ContainerCopyMessages.MonitorJobs.dialog.heading,
null,
ContainerCopyMessages.MonitorJobs.dialog.confirmButtonText,
() => handleClick(job, action, setUpdatingJobAction),
ContainerCopyMessages.MonitorJobs.dialog.cancelButtonText,
null,
action in dialogBody ? dialogBody[action as keyof typeof dialogBody](job.Name) : null,
);
};
const getMenuItems = (): IContextualMenuProps["items"] => {
const isThisJobUpdating = updatingJobAction?.jobName === job.Name;
const updatingAction = updatingJobAction?.action;
const baseItems = [
{
@@ -68,21 +32,21 @@ const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick
text: ContainerCopyMessages.MonitorJobs.Actions.pause,
iconProps: { iconName: "Pause" },
onClick: () => handleClick(job, CopyJobActions.pause, setUpdatingJobAction),
disabled: isThisJobUpdating,
disabled: isThisJobUpdating && updatingAction === CopyJobActions.pause,
},
{
key: CopyJobActions.cancel,
text: ContainerCopyMessages.MonitorJobs.Actions.cancel,
iconProps: { iconName: "Cancel" },
onClick: () => showActionConfirmationDialog(job, CopyJobActions.cancel),
disabled: isThisJobUpdating,
onClick: () => handleClick(job, CopyJobActions.cancel, setUpdatingJobAction),
disabled: isThisJobUpdating && updatingAction === CopyJobActions.cancel,
},
{
key: CopyJobActions.resume,
text: ContainerCopyMessages.MonitorJobs.Actions.resume,
iconProps: { iconName: "Play" },
onClick: () => handleClick(job, CopyJobActions.resume, setUpdatingJobAction),
disabled: isThisJobUpdating,
disabled: isThisJobUpdating && updatingAction === CopyJobActions.resume,
},
];
@@ -103,8 +67,8 @@ const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick
key: CopyJobActions.complete,
text: ContainerCopyMessages.MonitorJobs.Actions.complete,
iconProps: { iconName: "CheckMark" },
onClick: () => showActionConfirmationDialog(job, CopyJobActions.complete),
disabled: isThisJobUpdating,
onClick: () => handleClick(job, CopyJobActions.complete, setUpdatingJobAction),
disabled: isThisJobUpdating && updatingAction === CopyJobActions.complete,
});
}
return filteredItems;
@@ -122,8 +86,8 @@ const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick
data-test={`CopyJobActionMenu/Button:${job.Name}`}
role="button"
iconProps={{ iconName: "More", styles: { root: { fontSize: "20px", fontWeight: "bold" } } }}
menuProps={{ items: getMenuItems(), directionalHint: DirectionalHint.leftTopEdge, directionalHintFixed: false }}
menuIconProps={{ iconName: "", className: "hidden" }}
menuProps={{ items: getMenuItems() }}
menuIconProps={{ iconName: "" }}
ariaLabel={ContainerCopyMessages.MonitorJobs.Columns.actions}
title={ContainerCopyMessages.MonitorJobs.Columns.actions}
/>

View File

@@ -11,17 +11,9 @@ jest.mock("../../Actions/CopyJobActions", () => ({
jest.mock("./CopyJobColumns", () => ({
getColumns: jest.fn(() => [
{
key: "LastUpdatedTime",
name: "Date & time",
fieldName: "LastUpdatedTime",
minWidth: 140,
maxWidth: 300,
isResizable: true,
},
{
key: "Name",
name: "Job name",
name: "Name",
fieldName: "Name",
minWidth: 140,
maxWidth: 300,
@@ -173,165 +165,6 @@ describe("CopyJobsList", () => {
expect(screen.getByTestId("action-menu-job-2")).toBeInTheDocument();
expect(screen.getByTestId("action-menu-job-3")).toBeInTheDocument();
});
it("renders filter TextField with data-test attribute", () => {
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
const filterTextField = document.querySelector('[data-test="CopyJobsList/FilterTextField"]');
expect(filterTextField).toBeInTheDocument();
});
it("renders search TextField with correct placeholder", () => {
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
const searchInput = screen.getByPlaceholderText("Search jobs...");
expect(searchInput).toBeInTheDocument();
});
});
describe("Filtering", () => {
it("filters jobs by Name when text is entered", async () => {
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
const filterInput = screen.getByPlaceholderText("Search jobs...");
fireEvent.change(filterInput, { target: { value: "Job 1" } });
await waitFor(() => {
expect(screen.getByText("Test Job 1")).toBeInTheDocument();
expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument();
expect(screen.queryByText("Test Job 3")).not.toBeInTheDocument();
});
});
it("filters jobs case-insensitively", async () => {
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
const filterInput = screen.getByPlaceholderText("Search jobs...");
fireEvent.change(filterInput, { target: { value: "test job 1" } });
await waitFor(() => {
expect(screen.getByText("Test Job 1")).toBeInTheDocument();
expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument();
});
});
it("shows all jobs when filter text is empty", async () => {
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
const filterInput = screen.getByPlaceholderText("Search jobs...");
fireEvent.change(filterInput, { target: { value: "Job 1" } });
await waitFor(() => {
expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument();
});
fireEvent.change(filterInput, { target: { value: "" } });
await waitFor(() => {
expect(screen.getByText("Test Job 1")).toBeInTheDocument();
expect(screen.getByText("Test Job 2")).toBeInTheDocument();
expect(screen.getByText("Test Job 3")).toBeInTheDocument();
});
});
it("filters jobs by Status across all columns", async () => {
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
const filterInput = screen.getByPlaceholderText("Search jobs...");
fireEvent.change(filterInput, { target: { value: CopyJobStatusType.Running } });
await waitFor(() => {
expect(screen.getByText("Test Job 1")).toBeInTheDocument();
expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument();
expect(screen.queryByText("Test Job 3")).not.toBeInTheDocument();
});
});
it("filters jobs by Mode across all columns", async () => {
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
const filterInput = screen.getByPlaceholderText("Search jobs...");
fireEvent.change(filterInput, { target: { value: "Offline" } });
await waitFor(() => {
expect(screen.queryByText("Test Job 1")).not.toBeInTheDocument();
expect(screen.getByText("Test Job 2")).toBeInTheDocument();
expect(screen.queryByText("Test Job 3")).not.toBeInTheDocument();
});
});
it("shows no results when filter matches no jobs", async () => {
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
const filterInput = screen.getByPlaceholderText("Search jobs...");
fireEvent.change(filterInput, { target: { value: "NonExistentJob" } });
await waitFor(() => {
expect(screen.queryByText("Test Job 1")).not.toBeInTheDocument();
expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument();
expect(screen.queryByText("Test Job 3")).not.toBeInTheDocument();
});
});
it("filters by partial text match", async () => {
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
const filterInput = screen.getByPlaceholderText("Search jobs...");
fireEvent.change(filterInput, { target: { value: "Test" } });
await waitFor(() => {
expect(screen.getByText("Test Job 1")).toBeInTheDocument();
expect(screen.getByText("Test Job 2")).toBeInTheDocument();
expect(screen.getByText("Test Job 3")).toBeInTheDocument();
});
});
it("resets pagination when filter changes", async () => {
const manyJobs: CopyJobType[] = Array.from({ length: 25 }, (_, i) => ({
...mockJobs[0],
ID: `job-${i + 1}`,
Name: `Test Job ${i + 1}`,
}));
render(<CopyJobsList jobs={manyJobs} handleActionClick={mockHandleActionClick} pageSize={10} />);
// Navigate to page 2
fireEvent.click(screen.getByLabelText("Go to next page"));
await waitFor(() => {
expect(screen.getByText("Showing 11 - 20 of 25 items")).toBeInTheDocument();
});
// Apply filter - should reset to page 1
const filterInput = screen.getByPlaceholderText("Search jobs...");
fireEvent.change(filterInput, { target: { value: "Job 1" } });
await waitFor(() => {
// Filtered results show from the beginning
expect(screen.getByText("Test Job 1")).toBeInTheDocument();
});
});
it("updates filtered count in pager", async () => {
const manyJobs: CopyJobType[] = Array.from({ length: 25 }, (_, i) => ({
...mockJobs[0],
ID: `job-${i + 1}`,
Name: i < 5 ? `Alpha Job ${i + 1}` : `Beta Job ${i + 1}`,
}));
render(<CopyJobsList jobs={manyJobs} handleActionClick={mockHandleActionClick} pageSize={10} />);
expect(screen.getByText("Showing 1 - 10 of 25 items")).toBeInTheDocument();
const filterInput = screen.getByPlaceholderText("Search jobs...");
fireEvent.change(filterInput, { target: { value: "Alpha" } });
await waitFor(() => {
expect(screen.queryByText("Showing 1 - 10 of 25 items")).not.toBeInTheDocument();
// Pager should not be visible since filtered results (5) are less than page size (10)
expect(screen.queryByLabelText("Go to next page")).not.toBeInTheDocument();
});
});
});
describe("Pagination", () => {
@@ -509,7 +342,7 @@ describe("CopyJobsList", () => {
describe("Component Props", () => {
it("uses default page size when not provided", () => {
const manyJobs: CopyJobType[] = Array.from({ length: 20 }, (_, i) => ({
const manyJobs: CopyJobType[] = Array.from({ length: 12 }, (_, i) => ({
...mockJobs[0],
ID: `job-${i + 1}`,
Name: `Test Job ${i + 1}`,
@@ -518,7 +351,7 @@ describe("CopyJobsList", () => {
render(<CopyJobsList jobs={manyJobs} handleActionClick={mockHandleActionClick} />);
expect(screen.getByLabelText("Go to next page")).toBeInTheDocument();
expect(screen.getByText("Showing 1 - 15 of 20 items")).toBeInTheDocument();
expect(screen.getByText("Showing 1 - 10 of 12 items")).toBeInTheDocument();
});
it("passes correct props to getColumns function", async () => {
@@ -607,33 +440,7 @@ describe("CopyJobsList", () => {
render(<CopyJobsList jobs={largeJobsList} handleActionClick={mockHandleActionClick} />);
}).not.toThrow();
expect(screen.getByText("Showing 1 - 15 of 1000 items")).toBeInTheDocument();
});
it("handles filtering with null or undefined values gracefully", async () => {
const jobsWithNullValues: CopyJobType[] = [
{
...mockJobs[0],
ID: "job-with-values",
Name: "Valid Job",
},
{
...mockJobs[1],
ID: "job-null-name",
Name: undefined as unknown as string,
},
];
expect(() => {
render(<CopyJobsList jobs={jobsWithNullValues} handleActionClick={mockHandleActionClick} />);
}).not.toThrow();
const filterInput = screen.getByPlaceholderText("Search jobs...");
fireEvent.change(filterInput, { target: { value: "Valid" } });
await waitFor(() => {
expect(screen.getByText("Valid Job")).toBeInTheDocument();
});
expect(screen.getByText("Showing 1 - 10 of 1000 items")).toBeInTheDocument();
});
});
});

View File

@@ -12,9 +12,8 @@ import {
Stack,
Sticky,
StickyPositionType,
TextField,
} from "@fluentui/react";
import React, { useEffect, useMemo } from "react";
import React, { useEffect } from "react";
import Pager from "../../../../Common/Pager";
import { useThemeStore } from "../../../../hooks/useTheme";
import { getThemeTokens } from "../../../Theme/ThemeUtil";
@@ -31,15 +30,9 @@ interface CopyJobsListProps {
const styles = {
container: { height: "100%" } as React.CSSProperties,
stackItem: { position: "relative", marginBottom: "20px" } as React.CSSProperties,
filterContainer: {
margin: "15px 5px",
},
};
const PAGE_SIZE = 15;
// Columns to search across
const searchableFields = ["Name", "Status", "LastUpdatedTime", "Mode"];
const PAGE_SIZE = 10;
const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pageSize = PAGE_SIZE }) => {
const isDarkMode = useThemeStore((state) => state.isDarkMode);
@@ -48,23 +41,6 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
const [sortedJobs, setSortedJobs] = React.useState<CopyJobType[]>(jobs);
const [sortedColumnKey, setSortedColumnKey] = React.useState<string | undefined>(undefined);
const [isSortedDescending, setIsSortedDescending] = React.useState<boolean>(false);
const [filterText, setFilterText] = React.useState<string>("");
const filteredJobs = useMemo(() => {
if (!filterText) {
return sortedJobs;
}
const lowerFilterText = filterText.toLowerCase();
return sortedJobs.filter((job: any) => {
return searchableFields.some((field) => {
const value = job[field];
if (value === undefined || value === null) {
return false;
}
return String(value).toLowerCase().includes(lowerFilterText);
});
});
}, [sortedJobs, filterText]);
useEffect(() => {
setSortedJobs(jobs);
@@ -88,15 +64,7 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
setStartIndex(0);
};
const sortableColumns: IColumn[] = getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending);
const handleFilterTextChange = (
_event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
newValue?: string,
) => {
setFilterText(newValue || "");
setStartIndex(0);
};
const columns: IColumn[] = getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending);
const _handleRowClick = (job: CopyJobType) => {
openCopyJobDetailsPanel(job);
@@ -113,25 +81,14 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
return (
<div style={styles.container}>
<Stack verticalFill={true}>
<Stack.Item>
<div style={styles.filterContainer}>
<TextField
data-test="CopyJobsList/FilterTextField"
placeholder="Search jobs..."
ariaLabel="Search jobs"
value={filterText}
onChange={handleFilterTextChange}
/>
</div>
</Stack.Item>
<Stack.Item verticalFill={true} grow={1} shrink={1} style={styles.stackItem}>
<ScrollablePane scrollbarVisibility={ScrollbarVisibility.auto}>
<ShimmeredDetailsList
className="CopyJobListContainer"
onRenderRow={_onRenderRow}
checkboxVisibility={2}
columns={sortableColumns}
items={filteredJobs.slice(startIndex, startIndex + pageSize)}
columns={columns}
items={sortedJobs.slice(startIndex, startIndex + pageSize)}
enableShimmer={false}
constrainMode={ConstrainMode.unconstrained}
layoutMode={DetailsListLayoutMode.justified}
@@ -160,12 +117,12 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
/>
</ScrollablePane>
</Stack.Item>
{filteredJobs.length > pageSize && (
{sortedJobs.length > pageSize && (
<Stack.Item>
<Pager
disabled={false}
startIndex={startIndex}
totalCount={filteredJobs.length}
totalCount={sortedJobs.length}
pageSize={pageSize}
onLoadPage={(startIdx /* pageSize */) => {
setStartIndex(startIdx);

View File

@@ -1,27 +1,5 @@
@import "../../../less/Common/Constants.less";
.themedTextFieldStyles() {
.ms-TextField {
.ms-TextField-fieldGroup {
background-color: var(--colorNeutralBackground1);
border-color: var(--colorNeutralStroke1);
}
.ms-TextField-field {
color: var(--colorNeutralForeground1);
background-color: var(--colorNeutralBackground1);
&::placeholder {
color: var(--colorNeutralForeground4);
}
}
.ms-Label {
color: var(--colorNeutralForeground1);
}
}
}
// Common theme-aware classes
.themeText {
color: var(--colorNeutralForeground1);
@@ -141,8 +119,25 @@
filter: invert(1);
}
.themedTextFieldStyles();
.ms-TextField {
.ms-TextField-fieldGroup {
background-color: var(--colorNeutralBackground1);
border-color: var(--colorNeutralStroke1);
}
.ms-TextField-field {
color: var(--colorNeutralForeground1);
background-color: var(--colorNeutralBackground1);
&::placeholder {
color: var(--colorNeutralForeground4);
}
}
.ms-Label {
color: var(--colorNeutralForeground1);
}
}
.migrationTypeDescription {
p {
color: var(--colorNeutralForeground1);
@@ -178,11 +173,6 @@
width: 100%;
max-width: 100%;
margin: 0 auto;
body.isDarkMode & {
.themedTextFieldStyles();
}
.ms-DetailsList {
width: 100%;

View File

@@ -7,7 +7,7 @@ import {
AddGlobalSecondaryIndexPanelProps,
} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel";
import { useDatabases } from "Explorer/useDatabases";
import { isFabric, isFabricNative, openRestoreContainerDialog } from "Platform/Fabric/FabricUtil";
import { isFabric, isFabricNative } from "Platform/Fabric/FabricUtil";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import { traceOpen } from "Shared/Telemetry/TelemetryProcessor";
import { ReactTabKind, useTabs } from "hooks/useTabs";
@@ -35,7 +35,6 @@ import StoredProcedure from "./Tree/StoredProcedure";
import Trigger from "./Tree/Trigger";
import UserDefinedFunction from "./Tree/UserDefinedFunction";
import { useSelectedNode } from "./useSelectedNode";
import { extractFeatures } from "../Platform/Hosted/extractFeatures";
export interface CollectionContextMenuButtonParams {
databaseId: string;
@@ -61,17 +60,6 @@ export const createDatabaseContextMenu = (container: Explorer, databaseId: strin
},
];
if (isFabricNative() && !userContext.fabricContext?.isReadOnly) {
const features = extractFeatures();
if (features?.enableRestoreContainer) {
items.push({
iconSrc: AddCollectionIcon,
onClick: () => openRestoreContainerDialog(),
label: `Restore ${getCollectionName()}`,
});
}
}
if (!isFabricNative() && (userContext.apiType !== "Tables" || userContext.features.enableSDKoperations)) {
items.push({
iconSrc: DeleteDatabaseIcon,

View File

@@ -30,6 +30,7 @@ jest.mock("../../../Common/dataAccess/updateCollection", () => ({
dataMaskingPolicy: {
includedPaths: [],
excludedPaths: ["/excludedPath"],
isPolicyEnabled: true,
},
indexes: [],
}),
@@ -306,10 +307,12 @@ describe("SettingsComponent", () => {
dataMaskingContent: {
includedPaths: [],
excludedPaths: ["/excludedPath"],
isPolicyEnabled: true,
},
dataMaskingContentBaseline: {
includedPaths: [],
excludedPaths: [],
isPolicyEnabled: false,
},
isDataMaskingDirty: true,
});
@@ -323,6 +326,7 @@ describe("SettingsComponent", () => {
expect(wrapper.state("dataMaskingContentBaseline")).toEqual({
includedPaths: [],
excludedPaths: ["/excludedPath"],
isPolicyEnabled: true,
});
});
@@ -336,6 +340,7 @@ describe("SettingsComponent", () => {
const invalidPolicy: InvalidPolicy = {
includedPaths: "invalid",
excludedPaths: [],
isPolicyEnabled: false,
};
// Use type assertion since we're deliberately testing with invalid data
settingsComponentInstance["onDataMaskingContentChange"](invalidPolicy as unknown as DataModels.DataMaskingPolicy);
@@ -344,6 +349,7 @@ describe("SettingsComponent", () => {
expect(wrapper.state("dataMaskingContent")).toEqual({
includedPaths: "invalid",
excludedPaths: [],
isPolicyEnabled: false,
});
expect(wrapper.state("dataMaskingValidationErrors")).toEqual(["includedPaths must be an array"]);
@@ -358,6 +364,7 @@ describe("SettingsComponent", () => {
},
],
excludedPaths: ["/excludedPath"],
isPolicyEnabled: true,
};
settingsComponentInstance["onDataMaskingContentChange"](validPolicy);
@@ -381,6 +388,7 @@ describe("SettingsComponent", () => {
},
],
excludedPaths: ["/excludedPath1"],
isPolicyEnabled: false,
};
const modifiedPolicy = {
@@ -393,6 +401,7 @@ describe("SettingsComponent", () => {
},
],
excludedPaths: ["/excludedPath2"],
isPolicyEnabled: true,
};
// Set initial state

View File

@@ -16,7 +16,7 @@ import {
import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView";
import { useDatabases } from "Explorer/useDatabases";
import { isFabricNative } from "Platform/Fabric/FabricUtil";
import { isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { isCapabilityEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { isRunningOnPublicCloud } from "Utils/CloudUtils";
import * as React from "react";
import DiscardIcon from "../../../../images/discard.svg";
@@ -70,7 +70,6 @@ import {
getMongoNotification,
getTabTitle,
hasDatabaseSharedThroughput,
isDataMaskingEnabled,
isDirty,
parseConflictResolutionMode,
parseConflictResolutionProcedure,
@@ -687,14 +686,22 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.setState({ isComputedPropertiesDirty: isComputedPropertiesDirty });
private onDataMaskingContentChange = (newDataMasking: DataModels.DataMaskingPolicy): void => {
if (!newDataMasking.excludedPaths) {
newDataMasking.excludedPaths = [];
}
if (!newDataMasking.includedPaths) {
newDataMasking.includedPaths = [];
}
const validationErrors = [];
if (newDataMasking.includedPaths === undefined || newDataMasking.includedPaths === null) {
validationErrors.push("includedPaths is required");
} else if (!Array.isArray(newDataMasking.includedPaths)) {
if (!Array.isArray(newDataMasking.includedPaths)) {
validationErrors.push("includedPaths must be an array");
}
if (newDataMasking.excludedPaths !== undefined && !Array.isArray(newDataMasking.excludedPaths)) {
validationErrors.push("excludedPaths must be an array if provided");
if (!Array.isArray(newDataMasking.excludedPaths)) {
validationErrors.push("excludedPaths must be an array");
}
if (typeof newDataMasking.isPolicyEnabled !== "boolean") {
validationErrors.push("isPolicyEnabled must be a boolean");
}
this.setState({
@@ -835,6 +842,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
const dataMaskingContent: DataModels.DataMaskingPolicy = {
includedPaths: this.collection.dataMaskingPolicy?.()?.includedPaths || [],
excludedPaths: this.collection.dataMaskingPolicy?.()?.excludedPaths || [],
isPolicyEnabled: this.collection.dataMaskingPolicy?.()?.isPolicyEnabled ?? true,
};
const conflictResolutionPolicy: DataModels.ConflictResolutionPolicy =
this.collection.conflictResolutionPolicy && this.collection.conflictResolutionPolicy();
@@ -1065,8 +1073,8 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
newCollection.fullTextPolicy = this.state.fullTextPolicy;
// Only send data masking policy if it was modified (dirty) and data masking is enabled
if (this.state.isDataMaskingDirty && isDataMaskingEnabled(this.collection.dataMaskingPolicy?.())) {
// Only send data masking policy if it was modified (dirty)
if (this.state.isDataMaskingDirty && isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking)) {
newCollection.dataMaskingPolicy = this.state.dataMaskingContent;
}
@@ -1455,7 +1463,15 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
});
}
if (isDataMaskingEnabled(this.collection.dataMaskingPolicy?.())) {
// Check if DDM should be enabled
const shouldEnableDDM = (): boolean => {
const hasDataMaskingCapability = isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking);
const isSqlAccount = userContext.apiType === "SQL";
return isSqlAccount && hasDataMaskingCapability; // Only show for SQL accounts with DDM capability
};
if (shouldEnableDDM()) {
const dataMaskingComponentProps: DataMaskingComponentProps = {
shouldDiscardDataMasking: this.state.shouldDiscardDataMasking,
resetShouldDiscardDataMasking: this.resetShouldDiscardDataMasking,

View File

@@ -53,6 +53,7 @@ describe("DataMaskingComponent", () => {
},
],
excludedPaths: [],
isPolicyEnabled: false,
};
let changeContentCallback: () => void;
@@ -77,7 +78,7 @@ describe("DataMaskingComponent", () => {
<DataMaskingComponent
{...mockProps}
dataMaskingContent={samplePolicy}
dataMaskingContentBaseline={{ ...samplePolicy, excludedPaths: ["/excluded"] }}
dataMaskingContentBaseline={{ ...samplePolicy, isPolicyEnabled: true }}
/>,
);
@@ -122,7 +123,7 @@ describe("DataMaskingComponent", () => {
});
it("resets content when shouldDiscardDataMasking is true", async () => {
const baselinePolicy = { ...samplePolicy, excludedPaths: ["/excluded"] };
const baselinePolicy = { ...samplePolicy, isPolicyEnabled: true };
const wrapper = mount(
<DataMaskingComponent
@@ -158,7 +159,7 @@ describe("DataMaskingComponent", () => {
wrapper.update();
// Update baseline to trigger componentDidUpdate
const newBaseline = { ...samplePolicy, excludedPaths: ["/excluded"] };
const newBaseline = { ...samplePolicy, isPolicyEnabled: true };
wrapper.setProps({ dataMaskingContentBaseline: newBaseline });
expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true);
@@ -173,6 +174,7 @@ describe("DataMaskingComponent", () => {
const invalidPolicy: Record<string, unknown> = {
includedPaths: "not an array",
excludedPaths: [] as string[],
isPolicyEnabled: "not a boolean",
};
mockGetValue.mockReturnValue(JSON.stringify(invalidPolicy));
@@ -195,7 +197,7 @@ describe("DataMaskingComponent", () => {
wrapper.update();
// First change
const modifiedPolicy1 = { ...samplePolicy, excludedPaths: ["/path1"] };
const modifiedPolicy1 = { ...samplePolicy, isPolicyEnabled: true };
mockGetValue.mockReturnValue(JSON.stringify(modifiedPolicy1));
changeContentCallback();
expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true);

View File

@@ -1,10 +1,12 @@
import { MessageBar, MessageBarType, Stack } from "@fluentui/react";
import * as monaco from "monaco-editor";
import * as React from "react";
import * as Constants from "../../../../Common/Constants";
import * as DataModels from "../../../../Contracts/DataModels";
import { isCapabilityEnabled } from "../../../../Utils/CapabilityUtils";
import { loadMonaco } from "../../../LazyMonaco";
import { titleAndInputStackProps, unsavedEditorWarningMessage } from "../SettingsRenderUtils";
import { isDirty as isContentDirty, isDataMaskingEnabled } from "../SettingsUtils";
import { isDirty as isContentDirty } from "../SettingsUtils";
export interface DataMaskingComponentProps {
shouldDiscardDataMasking: boolean;
@@ -22,8 +24,16 @@ interface DataMaskingComponentState {
}
const emptyDataMaskingPolicy: DataModels.DataMaskingPolicy = {
includedPaths: [],
includedPaths: [
{
path: "/",
strategy: "Default",
startPosition: 0,
length: -1,
},
],
excludedPaths: [],
isPolicyEnabled: true,
};
export class DataMaskingComponent extends React.Component<DataMaskingComponentProps, DataMaskingComponentState> {
@@ -130,7 +140,7 @@ export class DataMaskingComponent extends React.Component<DataMaskingComponentPr
};
public render(): JSX.Element {
if (!isDataMaskingEnabled(this.props.dataMaskingContent)) {
if (!isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking)) {
return null;
}

View File

@@ -2,8 +2,6 @@ import * as Constants from "../../../Common/Constants";
import * as DataModels from "../../../Contracts/DataModels";
import * as ViewModels from "../../../Contracts/ViewModels";
import { isFabricNative } from "../../../Platform/Fabric/FabricUtil";
import { userContext } from "../../../UserContext";
import { isCapabilityEnabled } from "../../../Utils/CapabilityUtils";
import { MongoIndex } from "../../../Utils/arm/generatedClients/cosmos/types";
const zeroValue = 0;
@@ -90,19 +88,6 @@ export const hasDatabaseSharedThroughput = (collection: ViewModels.Collection):
return database?.isDatabaseShared() && !collection.offer();
};
export const isDataMaskingEnabled = (dataMaskingPolicy?: DataModels.DataMaskingPolicy): boolean => {
const isSqlAccount = userContext.apiType === "SQL";
if (!isSqlAccount) {
return false;
}
const hasDataMaskingCapability = isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking);
const hasDataMaskingPolicyFromCollection =
dataMaskingPolicy?.includedPaths?.length > 0 || dataMaskingPolicy?.excludedPaths?.length > 0;
return hasDataMaskingCapability || hasDataMaskingPolicyFromCollection;
};
export const parseConflictResolutionMode = (modeFromBackend: string): DataModels.ConflictResolutionMode => {
// Backend can contain different casing as it does case-insensitive comparisson
if (!modeFromBackend) {

View File

@@ -68,6 +68,7 @@ export const collection = {
dataMaskingPolicy: ko.observable<DataModels.DataMaskingPolicy>({
includedPaths: [],
excludedPaths: ["/excludedPath"],
isPolicyEnabled: true,
}),
readSettings: () => {
return;

View File

@@ -604,58 +604,6 @@ exports[`SettingsComponent renders 1`] = `
/>
</Stack>
</PivotItem>
<PivotItem
headerButtonProps={
{
"data-test": "settings-tab-header/DataMaskingTab",
}
}
headerText="Masking Policy (preview)"
itemKey="DataMaskingTab"
key="DataMaskingTab"
style={
{
"backgroundColor": "var(--colorNeutralBackground1)",
"color": "var(--colorNeutralForeground1)",
"marginTop": 20,
}
}
>
<Stack
styles={
{
"root": {
"backgroundColor": "var(--colorNeutralBackground1)",
"color": "var(--colorNeutralForeground1)",
},
}
}
>
<DataMaskingComponent
dataMaskingContent={
{
"excludedPaths": [
"/excludedPath",
],
"includedPaths": [],
}
}
dataMaskingContentBaseline={
{
"excludedPaths": [
"/excludedPath",
],
"includedPaths": [],
}
}
onDataMaskingContentChange={[Function]}
onDataMaskingDirtyChange={[Function]}
resetShouldDiscardDataMasking={[Function]}
shouldDiscardDataMasking={false}
validationErrors={[]}
/>
</Stack>
</PivotItem>
<PivotItem
headerButtonProps={
{

View File

@@ -113,7 +113,7 @@ export class ContainerSampleGenerator {
? await createMongoDocument(collection.databaseId, collection, shardKey, doc)
: await createDocument(collection, doc);
} catch (error) {
NotificationConsoleUtils.logConsoleError(error instanceof Error ? error.message : String(error));
NotificationConsoleUtils.logConsoleError(error);
}
}),
);

View File

@@ -329,10 +329,7 @@ export class NotificationConsoleComponent extends React.Component<
}
private static extractHeaderStatus(consoleData: ConsoleData) {
if (!consoleData?.message || typeof consoleData.message !== "string") {
return undefined;
}
return consoleData.message.split(":\n")[0];
return consoleData?.message.split(":\n")[0];
}
private onConsoleWasExpanded = (): void => {

View File

@@ -54,6 +54,6 @@
.mainButtonsContainer {
display: flex;
gap: 0 16px;
margin: 40px auto
margin-bottom: 10px
}

View File

@@ -16,8 +16,6 @@ import { sendMessage } from "Common/MessageHandler";
import { MessageTypes } from "Contracts/ExplorerContracts";
import { TerminalKind } from "Contracts/ViewModels";
import { SplashScreenButton } from "Explorer/SplashScreen/SplashScreenButton";
import { Keys } from "Localization/Keys.generated";
import { t } from "Localization/t";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import { traceOpen } from "Shared/Telemetry/TelemetryProcessor";
import { useCarousel } from "hooks/useCarousel";
@@ -166,23 +164,6 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
const container = explorer;
const subscriptions: Array<{ dispose: () => void }> = [];
let title: string;
let subtitle: string;
switch (userContext.apiType) {
case "Postgres":
title = t(Keys.splashScreen.title.postgres);
subtitle = t(Keys.splashScreen.subtitle.getStarted);
break;
case "VCoreMongo":
title = t(Keys.splashScreen.title.vcoreMongo);
subtitle = t(Keys.splashScreen.subtitle.getStarted);
break;
default:
title = t(Keys.splashScreen.title.default);
subtitle = t(Keys.splashScreen.subtitle.default);
}
React.useEffect(() => {
subscriptions.push(
{
@@ -921,11 +902,10 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
return (
<div className={styles.splashScreenContainer}>
<div className={styles.splashScreen}>
<h2 className={styles.title} role="heading" aria-label={title}>
{title}
<span className="activePatch"></span>
<h2 className={styles.title} role="heading" aria-label="Welcome to Azure Cosmos DB">
Welcome to Azure Cosmos DB<span className="activePatch"></span>
</h2>
<div className={styles.subtitle}>{subtitle}</div>
<div className={styles.subtitle}>Globally distributed, multi-model database service for any scale</div>
{getSplashScreenButtons()}
{useCarousel.getState().showCoachMark && (
<Coachmark

View File

@@ -789,7 +789,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
);
let partitionKeyProperties = useMemo(() => {
return partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader) =>
partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, "").replace(/["]+/g, ""),
partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, ""),
);
}, [partitionKeyPropertyHeaders]);
@@ -1470,11 +1470,7 @@ export const DocumentsTabComponent: React.FunctionComponent<IDocumentsTabCompone
const partitionKey = _partitionKey || (_collection && _collection.partitionKey);
const partitionKeyPropertyHeaders = _collection?.partitionKeyPropertyHeaders || partitionKey?.paths;
const partitionKeyProperties = partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader) =>
partitionKeyPropertyHeader
.replace(/[/]+/g, ".")
.substring(1)
.replace(/[']+/g, "")
.replace(/["]+/g, ""),
partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, ""),
);
return newDocumentId(rawDocument, partitionKeyProperties, partitionKeyValue);

View File

@@ -141,6 +141,7 @@ export default class Collection implements ViewModels.Collection {
const defaultDataMaskingPolicy: DataModels.DataMaskingPolicy = {
includedPaths: Array<{ path: string; strategy: string; startPosition: number; length: number }>(),
excludedPaths: Array<string>(),
isPolicyEnabled: true,
};
const observablePolicy = ko.observable(data.dataMaskingPolicy || defaultDataMaskingPolicy);
observablePolicy.subscribe(() => {});

View File

@@ -1,4 +1,3 @@
import "./i18n";
import React, { useState } from "react";
import ReactDOM from "react-dom";
import Arrow from "../images/Arrow.svg";

View File

@@ -1,14 +0,0 @@
{
"Projects": [
{
"LanguageSet": "Azure_LanguagesExt",
"LocItems": [
{
"SourceFile": "src\\Localization\\en\\Resources.json",
"CopyOption": "LangIDOnPath",
"OutputPath": "src\\Localization"
}
]
}
]
}

View File

@@ -1,13 +0,0 @@
{
"splashScreen": {
"title": {
"default": "Vítá vás Azure Cosmos DB",
"postgres": "Vítá vás Azure Cosmos DB for PostgreSQL",
"vcoreMongo": "Vítá vás Azure DocumentDB (s kompatibilitou MongoDB)"
},
"subtitle": {
"default": "Globálně distribuovaná databázová služba s více modely pro libovolné škálování",
"getStarted": "Začněte s našimi ukázkovými datovými sadami, dokumentací a dalšími nástroji."
}
}
}

View File

@@ -1,13 +0,0 @@
{
"splashScreen": {
"title": {
"default": "Willkommen bei Azure Cosmos DB",
"postgres": "Willkommen bei Azure Cosmos DB for PostgreSQL",
"vcoreMongo": "Willkommen bei Azure DocumentDB (mit MongoDB-Kompatibilität)"
},
"subtitle": {
"default": "Global verteilter Datenbankdienst mit Unterstützung mehrerer Datenmodelle in jeder Größenordnung",
"getStarted": "Erste Schritte mit unseren Beispieldatensätzen, der Dokumentation und weiteren Tools."
}
}
}

View File

@@ -1,13 +0,0 @@
{
"splashScreen": {
"title": {
"default": "Welcome to Azure Cosmos DB",
"postgres": "Welcome to Azure Cosmos DB for PostgreSQL",
"vcoreMongo": "Welcome to Azure DocumentDB (with MongoDB compatibility)"
},
"subtitle": {
"default": "Globally distributed, multi-model database service for any scale",
"getStarted": "Get started with our sample datasets, documentation, and additional tools."
}
}
}

View File

@@ -1,13 +0,0 @@
{
"splashScreen": {
"title": {
"default": "Le presentamos Azure Cosmos DB",
"postgres": "Le damos la bienvenida a Azure Cosmos DB for PostgreSQL",
"vcoreMongo": "Le damos la bienvenida a Azure DocumentDB (con compatibilidad con MongoDB)"
},
"subtitle": {
"default": "Servicio de base de datos multimodelo distribuido globalmente para cualquier escala",
"getStarted": "Introducción a nuestros conjuntos de datos de ejemplo, documentación y herramientas adicionales."
}
}
}

View File

@@ -1,13 +0,0 @@
{
"splashScreen": {
"title": {
"default": "Bienvenue dans Azure Cosmos DB",
"postgres": "Bienvenue à Azure Cosmos DB for PostgreSQL",
"vcoreMongo": "Bienvenue à Azure DocumentDB (avec compatibilité MongoDB)"
},
"subtitle": {
"default": "Service de base de données multimodèle, mondialement distribuée et disponible à toute échelle",
"getStarted": "Commencez avec nos jeux de données dexemple, notre documentation et nos outils supplémentaires."
}
}
}

View File

@@ -1,13 +0,0 @@
{
"splashScreen": {
"title": {
"default": "Üdvözli az Azure Cosmos DB",
"postgres": "Üdvözli az Azure Cosmos DB for PostgreSQL",
"vcoreMongo": "Üdvözli az Azure DocumentDB (MongoDB-kompatibilitással)"
},
"subtitle": {
"default": "Globálisan elosztott, többmodelles adatbázis-szolgáltatás bármilyen mérethez",
"getStarted": "Ismerje meg a minta adathalmazok, a dokumentáció és a további eszközök használatának első lépéseit."
}
}
}

View File

@@ -1,13 +0,0 @@
{
"splashScreen": {
"title": {
"default": "Selamat Datang di Azure Cosmos DB",
"postgres": "Selamat datang di Azure Cosmos DB for PostgreSQL",
"vcoreMongo": "Selamat datang di Azure DocumentDB (dengan kompatibilitas MongoDB)"
},
"subtitle": {
"default": "Layanan database multimodel yang didistribusikan secara global untuk skala apa saja",
"getStarted": "Mulai dengan himpunan data sampel, dokumentasi, dan alat tambahan kami."
}
}
}

View File

@@ -1,13 +0,0 @@
{
"splashScreen": {
"title": {
"default": "Benvenuto in Azure Cosmos DB",
"postgres": "Benvenuti in Azure Cosmos DB for PostgreSQL",
"vcoreMongo": "Benvenuti in Azure DocumentDB (con compatibilità MongoDB)"
},
"subtitle": {
"default": "Servizio database multimodello distribuito a livello globale a qualsiasi livello di scalabilità",
"getStarted": "Inizia con i nostri set di dati di esempio, la documentazione e gli strumenti aggiuntivi."
}
}
}

View File

@@ -1,13 +0,0 @@
{
"splashScreen": {
"title": {
"default": "Azure Cosmos DB へようこそ",
"postgres": "Azure Cosmos DB for PostgreSQL へようこそ",
"vcoreMongo": "Azure DocumentDB (MongoDB 互換) へようこそ"
},
"subtitle": {
"default": "あらゆるスケールに対応するグローバル分散型のマルチモデル データベース サーバー",
"getStarted": "サンプル データセット、ドキュメント、追加ツールを使用して開始してください。"
}
}
}

View File

@@ -1,13 +0,0 @@
{
"splashScreen": {
"title": {
"default": "Azure Cosmos DB 시작",
"postgres": "Azure Cosmos DB for PostgreSQL 시작",
"vcoreMongo": "Azure DocumentDB 시작(MongoDB 호환성 포함)"
},
"subtitle": {
"default": "모든 규모에 대해 전역적으로 분산된 다중 모델 데이터베이스 서비스",
"getStarted": "샘플 데이터 세트, 설명서 및 추가 도구를 시작하세요."
}
}
}

View File

@@ -1,13 +0,0 @@
{
"splashScreen": {
"title": {
"default": "Welkom bij Azure Cosmos DB",
"postgres": "Welkom bij Azure Cosmos DB for PostgreSQL",
"vcoreMongo": "Welkom bij Azure DocumentDB (met MongoDB-compatibiliteit)"
},
"subtitle": {
"default": "Wereldwijd gedistribueerde, multi-modeldatabase-service voor elke schaalgrootte",
"getStarted": "Ga aan de slag met onze voorbeelddatasets, documentatie en extra tools."
}
}
}

View File

@@ -1,13 +0,0 @@
{
"splashScreen": {
"title": {
"default": "Azure Cosmos DB — Zapraszamy!",
"postgres": "Azure Cosmos DB for PostgreSQL — Zapraszamy!",
"vcoreMongo": "Witamy w usłudze Azure DocumentDB (ze zgodnością z bazą danych MongoDB)"
},
"subtitle": {
"default": "Globalnie rozproszona, wielomodelowa usługa bazy danych na dowolną skalę",
"getStarted": "Rozpocznij pracę z naszymi przykładowymi zestawami danych, dokumentacją i dodatkowymi narzędziami."
}
}
}

View File

@@ -1,13 +0,0 @@
{
"splashScreen": {
"title": {
"default": "Bem-vindo ao Azure Cosmos DB",
"postgres": "Bem-vindo ao Azure Cosmos DB for PostgreSQL",
"vcoreMongo": "Bem-vindo ao Azure DocumentDB (com compatibilidade com MongoDB)"
},
"subtitle": {
"default": "Serviço de multimodelo de banco de dados globalmente distribuído para qualquer escala",
"getStarted": "Comece com nossos conjuntos de dados de exemplo, documentação e ferramentas adicionais."
}
}
}

View File

@@ -1,13 +0,0 @@
{
"splashScreen": {
"title": {
"default": "Bem-vindo ao Azure Cosmos DB",
"postgres": "Bem-vindo ao Azure Cosmos DB para PostgreSQL",
"vcoreMongo": "Bem-vindo ao Azure DocumentDB (com compatibilidade do MongoDB)"
},
"subtitle": {
"default": "Serviço de base de dados com múltiplos modelos distribuído globalmente para qualquer dimensionamento",
"getStarted": "Comece a trabalhar com os nossos conjuntos de dados de exemplo, documentação e ferramentas adicionais."
}
}
}

View File

@@ -1,13 +0,0 @@
{
"splashScreen": {
"title": {
"default": "Вас приветствует Azure Cosmos DB",
"postgres": "Добро пожаловать в Azure Cosmos DB for PostgreSQL",
"vcoreMongo": "Добро пожаловать в Azure DocumentDB (с совместимостью с MongoDB)"
},
"subtitle": {
"default": "Глобально распределенная многомодельная служба базы данных для использования в любом масштабе",
"getStarted": "Начните работу с нашими примерами наборов данных, документацией и дополнительными инструментами."
}
}
}

View File

@@ -1,13 +0,0 @@
{
"splashScreen": {
"title": {
"default": "Välkommen till Azure Cosmos DB",
"postgres": "Välkommen till Azure Cosmos DB for PostgreSQL",
"vcoreMongo": "Välkommen till Azure DocumentDB (med MongoDB-kompatibilitet)"
},
"subtitle": {
"default": "Globalt distribuerad databas för flera datamodeller oavsett skala",
"getStarted": "Kom igång med våra exempeldatamängder, dokumentation och extra verktyg."
}
}
}

View File

@@ -1,24 +0,0 @@
import i18n from "../i18n";
import type enResources from "./en/Resources.json";
/**
* Derives a union of all dot-notation key paths from a nested JSON object type.
* e.g. { buttons: { save: "Save" } } → "buttons.save"
*/
type NestedKeyOf<T, P extends string = ""> = {
[K in keyof T & string]: T[K] extends Record<string, unknown>
? NestedKeyOf<T[K], P extends "" ? K : `${P}.${K}`>
: P extends ""
? K
: `${P}.${K}`;
}[keyof T & string];
/** All valid translation keys derived from en/Resources.json */
export type ResourceKey = NestedKeyOf<typeof enResources>;
/**
* Type-safe translation function bound to the "Resources" namespace.
* Use this everywhere—class components, functional components, and non-React code.
*/
export const t = (key: ResourceKey, options?: Record<string, unknown>): string =>
(i18n.t as (key: string, options?: unknown) => string)(key, { ns: "Resources", ...options });

View File

@@ -1,13 +0,0 @@
{
"splashScreen": {
"title": {
"default": "Azure Cosmos DB'ye hoş geldiniz",
"postgres": "Azure Cosmos DB for PostgreSQL'e hoş geldiniz",
"vcoreMongo": "Azure DocumentDB'ye (MongoDB uyumluluğu ile) hoş geldiniz"
},
"subtitle": {
"default": "Her ölçeğe uygun, global olarak dağıtılan çok modelli veritabanı hizmeti",
"getStarted": "Örnek veri kümelerimizi, belgelerimizi ve ek araçlarımızı kullanmaya başlayın."
}
}
}

View File

@@ -1,13 +0,0 @@
{
"splashScreen": {
"title": {
"default": "欢迎使用 Azure Cosmos DB",
"postgres": "欢迎使用 Azure Cosmos DB for PostgreSQL",
"vcoreMongo": "欢迎使用 Azure DocumentDB (具有 MongoDB 兼容性)"
},
"subtitle": {
"default": "任何规模的全球分布式多模型数据库服务",
"getStarted": "开始使用我们的示例数据集、文档和其他工具。"
}
}
}

View File

@@ -1,13 +0,0 @@
{
"splashScreen": {
"title": {
"default": "歡迎使用 Azure Cosmos DB",
"postgres": "歡迎使用 Azure Cosmos DB for PostgreSQL",
"vcoreMongo": "歡迎使用 Azure DocumentDB (具 MongoDB 相容性)"
},
"subtitle": {
"default": "適用於任何規模的全域散發、多模型資料庫服務",
"getStarted": "開始使用我們的樣本資料集、文件和其他工具。"
}
}
}

View File

@@ -105,12 +105,9 @@ const App = (): JSX.Element => {
// Scenario-based health tracking: start ApplicationLoad and complete phases.
const { startScenario, completePhase } = useMetricScenario();
React.useEffect(() => {
// Only start scenario after config is initialized to avoid race conditions
// with message handlers that depend on configContext.platform
if (config) {
startScenario(MetricScenario.ApplicationLoad);
}
}, [config, startScenario]);
startScenario(MetricScenario.ApplicationLoad);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
React.useEffect(() => {
if (explorer) {
@@ -119,9 +116,6 @@ const App = (): JSX.Element => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [explorer]);
// Track interactive phase for both ContainerCopyPanel and DivExplorer paths
useInteractive(MetricScenario.ApplicationLoad, !!config);
if (!explorer) {
return <LoadingExplorer />;
}
@@ -134,7 +128,6 @@ const App = (): JSX.Element => {
<>
<ContainerCopyPanel explorer={explorer} />
<SidePanel />
<Dialog />
</>
) : (
<DivExplorer explorer={explorer} />
@@ -148,6 +141,7 @@ const App = (): JSX.Element => {
const DivExplorer: React.FC<{ explorer: Explorer }> = ({ explorer }) => {
const isCarouselOpen = useCarousel((state) => state.shouldOpen);
const isCopilotCarouselOpen = useCarousel((state) => state.showCopilotCarousel);
useInteractive(MetricScenario.ApplicationLoad);
return (
<div

View File

@@ -1,182 +0,0 @@
import { ARMError } from "../Utils/arm/request";
import { isExpectedError } from "./ErrorClassification";
describe("ErrorClassification", () => {
describe("isExpectedError", () => {
describe("ARMError with expected codes", () => {
it("returns true for AuthorizationFailed code", () => {
const error = new ARMError("Authorization failed");
error.code = "AuthorizationFailed";
expect(isExpectedError(error)).toBe(true);
});
it("returns true for Forbidden code", () => {
const error = new ARMError("Forbidden");
error.code = "Forbidden";
expect(isExpectedError(error)).toBe(true);
});
it("returns true for Unauthorized code", () => {
const error = new ARMError("Unauthorized");
error.code = "Unauthorized";
expect(isExpectedError(error)).toBe(true);
});
it("returns true for InvalidAuthenticationToken code", () => {
const error = new ARMError("Invalid token");
error.code = "InvalidAuthenticationToken";
expect(isExpectedError(error)).toBe(true);
});
it("returns true for ExpiredAuthenticationToken code", () => {
const error = new ARMError("Token expired");
error.code = "ExpiredAuthenticationToken";
expect(isExpectedError(error)).toBe(true);
});
it("returns true for numeric 401 code", () => {
const error = new ARMError("Unauthorized");
error.code = 401;
expect(isExpectedError(error)).toBe(true);
});
it("returns true for numeric 403 code", () => {
const error = new ARMError("Forbidden");
error.code = 403;
expect(isExpectedError(error)).toBe(true);
});
it("returns false for unexpected ARM error code", () => {
const error = new ARMError("Internal error");
error.code = "InternalServerError";
expect(isExpectedError(error)).toBe(false);
});
it("returns false for numeric 500 code", () => {
const error = new ARMError("Server error");
error.code = 500;
expect(isExpectedError(error)).toBe(false);
});
});
describe("MSAL AuthError with expected errorCodes", () => {
it("returns true for popup_window_error", () => {
const error = { errorCode: "popup_window_error", message: "Popup blocked" };
expect(isExpectedError(error)).toBe(true);
});
it("returns true for interaction_required", () => {
const error = { errorCode: "interaction_required", message: "User interaction required" };
expect(isExpectedError(error)).toBe(true);
});
it("returns true for user_cancelled", () => {
const error = { errorCode: "user_cancelled", message: "User cancelled" };
expect(isExpectedError(error)).toBe(true);
});
it("returns true for consent_required", () => {
const error = { errorCode: "consent_required", message: "Consent required" };
expect(isExpectedError(error)).toBe(true);
});
it("returns true for login_required", () => {
const error = { errorCode: "login_required", message: "Login required" };
expect(isExpectedError(error)).toBe(true);
});
it("returns true for no_account_error", () => {
const error = { errorCode: "no_account_error", message: "No account" };
expect(isExpectedError(error)).toBe(true);
});
it("returns false for unexpected MSAL error code", () => {
const error = { errorCode: "unknown_error", message: "Unknown" };
expect(isExpectedError(error)).toBe(false);
});
});
describe("HTTP status codes", () => {
it("returns true for error with status 401", () => {
const error = { status: 401, message: "Unauthorized" };
expect(isExpectedError(error)).toBe(true);
});
it("returns true for error with status 403", () => {
const error = { status: 403, message: "Forbidden" };
expect(isExpectedError(error)).toBe(true);
});
it("returns false for error with status 500", () => {
const error = { status: 500, message: "Internal Server Error" };
expect(isExpectedError(error)).toBe(false);
});
it("returns false for error with status 404", () => {
const error = { status: 404, message: "Not Found" };
expect(isExpectedError(error)).toBe(false);
});
});
describe("Firewall error message pattern", () => {
it("returns true for firewall error in Error message", () => {
const error = new Error("Request blocked by firewall");
expect(isExpectedError(error)).toBe(true);
});
it("returns true for IP not allowed error", () => {
const error = new Error("Client IP address is not allowed");
expect(isExpectedError(error)).toBe(true);
});
it("returns true for ip not allowed (no 'address')", () => {
const error = new Error("Your ip not allowed to access this resource");
expect(isExpectedError(error)).toBe(true);
});
it("returns true for string error with firewall", () => {
expect(isExpectedError("firewall rules prevent access")).toBe(true);
});
it("returns true for case-insensitive firewall match", () => {
const error = new Error("FIREWALL blocked request");
expect(isExpectedError(error)).toBe(true);
});
it("returns false for unrelated error message", () => {
const error = new Error("Database connection failed");
expect(isExpectedError(error)).toBe(false);
});
});
describe("Edge cases", () => {
it("returns false for null", () => {
expect(isExpectedError(null)).toBe(false);
});
it("returns false for undefined", () => {
expect(isExpectedError(undefined)).toBe(false);
});
it("returns false for empty object", () => {
expect(isExpectedError({})).toBe(false);
});
it("returns false for plain Error without expected patterns", () => {
const error = new Error("Something went wrong");
expect(isExpectedError(error)).toBe(false);
});
it("returns false for string without firewall pattern", () => {
expect(isExpectedError("Generic error occurred")).toBe(false);
});
it("handles error with multiple matching criteria", () => {
// ARMError with both code and firewall message
const error = new ARMError("Request blocked by firewall");
error.code = "Forbidden";
expect(isExpectedError(error)).toBe(true);
});
});
});
});

View File

@@ -1,109 +0,0 @@
import { ARMError } from "../Utils/arm/request";
/**
* Expected error codes that should not mark scenarios as unhealthy.
* These represent expected failures like auth issues, permission errors, and user actions.
*/
// ARM error codes (string)
const EXPECTED_ARM_ERROR_CODES: Set<string> = new Set([
"AuthorizationFailed",
"Forbidden",
"Unauthorized",
"AuthenticationFailed",
"InvalidAuthenticationToken",
"ExpiredAuthenticationToken",
"AuthorizationPermissionMismatch",
]);
// HTTP status codes that indicate expected failures
const EXPECTED_HTTP_STATUS_CODES: Set<number> = new Set([
401, // Unauthorized
403, // Forbidden
]);
// MSAL error codes (string)
const EXPECTED_MSAL_ERROR_CODES: Set<string> = new Set([
"popup_window_error",
"interaction_required",
"user_cancelled",
"consent_required",
"login_required",
"no_account_error",
"monitor_window_timeout",
"empty_window_error",
]);
// Firewall error message pattern (only case where we check message content)
const FIREWALL_ERROR_PATTERN = /firewall|ip\s*(address)?\s*(is\s*)?not\s*allowed/i;
/**
* Interface for MSAL AuthError-like objects
*/
interface MsalAuthError {
errorCode?: string;
}
/**
* Interface for errors with HTTP status
*/
interface HttpError {
status?: number;
}
/**
* Determines if an error is an expected failure that should not mark the scenario as unhealthy.
*
* Expected failures include:
* - Authentication/authorization errors (user not logged in, permissions)
* - Firewall blocking errors
* - User-cancelled operations
*
* @param error - The error to classify
* @returns true if the error is expected and should not affect health metrics
*/
export function isExpectedError(error: unknown): boolean {
if (!error) {
return false;
}
// Check ARMError code
if (error instanceof ARMError && error.code !== undefined) {
if (typeof error.code === "string" && EXPECTED_ARM_ERROR_CODES.has(error.code)) {
return true;
}
if (typeof error.code === "number" && EXPECTED_HTTP_STATUS_CODES.has(error.code)) {
return true;
}
}
// Check for MSAL AuthError (has errorCode property)
const msalError = error as MsalAuthError;
if (msalError.errorCode && typeof msalError.errorCode === "string") {
if (EXPECTED_MSAL_ERROR_CODES.has(msalError.errorCode)) {
return true;
}
}
// Check HTTP status on generic errors
const httpError = error as HttpError;
if (httpError.status && typeof httpError.status === "number") {
if (EXPECTED_HTTP_STATUS_CODES.has(httpError.status)) {
return true;
}
}
// Check for firewall error in message (the only message-based check)
if (error instanceof Error && error.message) {
if (FIREWALL_ERROR_PATTERN.test(error.message)) {
return true;
}
}
// Check for string errors with firewall pattern
if (typeof error === "string" && FIREWALL_ERROR_PATTERN.test(error)) {
return true;
}
return false;
}

View File

@@ -15,11 +15,6 @@ export const reportUnhealthy = (scenario: MetricScenario, platform: Platform, ap
send({ platform, api, scenario, healthy: false });
const send = async (event: MetricEvent): Promise<Response> => {
// Skip metrics emission during local development
if (process.env.NODE_ENV === "development") {
return Promise.resolve(new Response(null, { status: 200 }));
}
const url = createUri(configContext?.PORTAL_BACKEND_ENDPOINT, RELATIVE_PATH);
const authHeader = getAuthorizationHeader();

View File

@@ -1,231 +0,0 @@
/**
* @jest-environment jsdom
*/
import { configContext } from "../ConfigContext";
import { updateUserContext } from "../UserContext";
import MetricScenario, { reportHealthy, reportUnhealthy } from "./MetricEvents";
import { ApplicationMetricPhase, CommonMetricPhase } from "./ScenarioConfig";
import { scenarioMonitor } from "./ScenarioMonitor";
// Mock the MetricEvents module
jest.mock("./MetricEvents", () => ({
__esModule: true,
default: {
ApplicationLoad: "ApplicationLoad",
DatabaseLoad: "DatabaseLoad",
},
reportHealthy: jest.fn().mockResolvedValue({ ok: true }),
reportUnhealthy: jest.fn().mockResolvedValue({ ok: true }),
}));
// Mock configContext
jest.mock("../ConfigContext", () => ({
configContext: {
platform: "Portal",
PORTAL_BACKEND_ENDPOINT: "https://test.portal.azure.com",
},
Platform: {
Portal: "Portal",
Hosted: "Hosted",
Emulator: "Emulator",
Fabric: "Fabric",
},
}));
describe("ScenarioMonitor", () => {
beforeEach(() => {
jest.clearAllMocks();
// Use legacy fake timers to avoid conflicts with performance API
jest.useFakeTimers({ legacyFakeTimers: true });
// Ensure performance mock is available (setupTests.ts sets this but fake timers may override)
if (typeof performance.mark !== "function") {
Object.defineProperty(global, "performance", {
writable: true,
configurable: true,
value: {
mark: jest.fn(),
measure: jest.fn(),
clearMarks: jest.fn(),
clearMeasures: jest.fn(),
getEntriesByName: jest.fn().mockReturnValue([{ startTime: 0 }]),
getEntriesByType: jest.fn().mockReturnValue([]),
now: jest.fn(() => Date.now()),
timeOrigin: Date.now(),
},
});
}
// Reset userContext
updateUserContext({
apiType: "SQL",
});
// Reset the scenario monitor to clear any previous state
scenarioMonitor.reset();
});
afterEach(() => {
// Reset scenarios before switching to real timers
scenarioMonitor.reset();
jest.useRealTimers();
});
describe("markExpectedFailure", () => {
it("sets hasExpectedFailure flag on active scenarios", () => {
// Start a scenario
scenarioMonitor.start(MetricScenario.ApplicationLoad);
// Mark expected failure
scenarioMonitor.markExpectedFailure();
// Let timeout fire - should emit healthy because of expected failure
jest.advanceTimersByTime(10000);
expect(reportHealthy).toHaveBeenCalledWith(MetricScenario.ApplicationLoad, configContext.platform, "SQL");
expect(reportUnhealthy).not.toHaveBeenCalled();
});
it("sets flag on multiple active scenarios", () => {
// Start two scenarios
scenarioMonitor.start(MetricScenario.ApplicationLoad);
scenarioMonitor.start(MetricScenario.DatabaseLoad);
// Mark expected failure - should affect both
scenarioMonitor.markExpectedFailure();
// Let timeouts fire
jest.advanceTimersByTime(10000);
expect(reportHealthy).toHaveBeenCalledTimes(2);
expect(reportUnhealthy).not.toHaveBeenCalled();
});
it("does not affect already emitted scenarios", () => {
// Start scenario
scenarioMonitor.start(MetricScenario.ApplicationLoad);
// Complete all phases to emit
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized);
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, CommonMetricPhase.Interactive);
// Now mark expected failure - should not change anything
scenarioMonitor.markExpectedFailure();
// Healthy was called when phases completed
expect(reportHealthy).toHaveBeenCalledTimes(1);
});
});
describe("timeout behavior", () => {
it("emits unhealthy on timeout without expected failure", () => {
scenarioMonitor.start(MetricScenario.ApplicationLoad);
// Let timeout fire without marking expected failure
jest.advanceTimersByTime(10000);
expect(reportUnhealthy).toHaveBeenCalledWith(MetricScenario.ApplicationLoad, configContext.platform, "SQL");
expect(reportHealthy).not.toHaveBeenCalled();
});
it("emits healthy on timeout with expected failure", () => {
scenarioMonitor.start(MetricScenario.ApplicationLoad);
// Mark expected failure
scenarioMonitor.markExpectedFailure();
// Let timeout fire
jest.advanceTimersByTime(10000);
expect(reportHealthy).toHaveBeenCalledWith(MetricScenario.ApplicationLoad, configContext.platform, "SQL");
expect(reportUnhealthy).not.toHaveBeenCalled();
});
it("emits healthy even with partial phase completion and expected failure", () => {
scenarioMonitor.start(MetricScenario.ApplicationLoad);
// Complete one phase
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized);
// Mark expected failure
scenarioMonitor.markExpectedFailure();
// Let timeout fire (Interactive phase not completed)
jest.advanceTimersByTime(10000);
expect(reportHealthy).toHaveBeenCalled();
expect(reportUnhealthy).not.toHaveBeenCalled();
});
});
describe("failPhase behavior", () => {
it("emits unhealthy immediately on unexpected failure", () => {
scenarioMonitor.start(MetricScenario.DatabaseLoad);
// Fail a phase (simulating unexpected error)
scenarioMonitor.failPhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabasesFetched);
// Should emit unhealthy immediately, not wait for timeout
expect(reportUnhealthy).toHaveBeenCalledWith(MetricScenario.DatabaseLoad, configContext.platform, "SQL");
});
it("does not emit twice after failPhase and timeout", () => {
scenarioMonitor.start(MetricScenario.DatabaseLoad);
// Fail a phase
scenarioMonitor.failPhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabasesFetched);
// Let timeout fire
jest.advanceTimersByTime(10000);
// Should only have emitted once (from failPhase)
expect(reportUnhealthy).toHaveBeenCalledTimes(1);
});
});
describe("completePhase behavior", () => {
it("emits healthy when all phases complete", () => {
scenarioMonitor.start(MetricScenario.ApplicationLoad);
// Complete all required phases
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized);
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, CommonMetricPhase.Interactive);
expect(reportHealthy).toHaveBeenCalledWith(MetricScenario.ApplicationLoad, configContext.platform, "SQL");
});
it("does not emit until all phases complete", () => {
scenarioMonitor.start(MetricScenario.ApplicationLoad);
// Complete only one phase
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized);
expect(reportHealthy).not.toHaveBeenCalled();
expect(reportUnhealthy).not.toHaveBeenCalled();
});
});
describe("scenario isolation", () => {
it("expected failure on one scenario does not affect others after completion", () => {
// Start both scenarios
scenarioMonitor.start(MetricScenario.ApplicationLoad);
scenarioMonitor.start(MetricScenario.DatabaseLoad);
// Complete ApplicationLoad
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized);
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, CommonMetricPhase.Interactive);
// Now mark expected failure - should only affect DatabaseLoad
scenarioMonitor.markExpectedFailure();
// Let DatabaseLoad timeout
jest.advanceTimersByTime(10000);
// ApplicationLoad emitted healthy on completion
// DatabaseLoad emits healthy on timeout (expected failure)
expect(reportHealthy).toHaveBeenCalledTimes(2);
expect(reportUnhealthy).not.toHaveBeenCalled();
});
});
});

View File

@@ -21,7 +21,6 @@ interface InternalScenarioContext {
phases: Map<MetricPhase, PhaseContext>; // Track start/end for each phase
timeoutId?: number;
emitted: boolean;
hasExpectedFailure: boolean; // Flag for expected failures (auth, firewall, etc.)
}
class ScenarioMonitor {
@@ -56,13 +55,6 @@ class ScenarioMonitor {
});
}
private devLog(msg: string) {
if (process.env.NODE_ENV === "development") {
// eslint-disable-next-line no-console
console.log(`[Metrics] ${msg}`);
}
}
start(scenario: MetricScenario) {
if (this.contexts.has(scenario)) {
return;
@@ -83,7 +75,6 @@ class ScenarioMonitor {
failed: new Set<MetricPhase>(),
phases: new Map<MetricPhase, PhaseContext>(),
emitted: false,
hasExpectedFailure: false,
};
// Start all required phases at scenario start time
@@ -93,10 +84,6 @@ class ScenarioMonitor {
ctx.phases.set(phase, { startMarkName: phaseStartMarkName });
});
this.devLog(
`scenario_start: ${scenario} | phases=${config.requiredPhases.join(", ")} | timeout=${config.timeoutMs}ms`,
);
traceMark(Action.MetricsScenario, {
event: "scenario_start",
scenario,
@@ -104,28 +91,7 @@ class ScenarioMonitor {
timeoutMs: config.timeoutMs,
});
ctx.timeoutId = window.setTimeout(() => {
const missingPhases = ctx.config.requiredPhases.filter((p) => !ctx.completed.has(p));
this.devLog(
`timeout: ${scenario} | missing=[${missingPhases.join(", ")}] | completed=[${Array.from(ctx.completed).join(
", ",
)}] | documentHidden=${document.hidden} | hasExpectedFailure=${ctx.hasExpectedFailure}`,
);
traceMark(Action.MetricsScenario, {
event: "scenario_timeout",
scenario,
missingPhases: missingPhases.join(","),
completedPhases: Array.from(ctx.completed).join(","),
documentHidden: document.hidden,
hasExpectedFailure: ctx.hasExpectedFailure,
});
// If an expected failure occurred (auth, firewall, etc.), emit healthy instead of unhealthy
const healthy = ctx.hasExpectedFailure;
this.emit(ctx, healthy, true);
}, config.timeoutMs);
ctx.timeoutId = window.setTimeout(() => this.emit(ctx, false, true), config.timeoutMs);
this.contexts.set(scenario, ctx);
}
@@ -164,12 +130,6 @@ class ScenarioMonitor {
const endTimeISO = endEntry ? new Date(navigationStart + endEntry.startTime).toISOString() : undefined;
const durationMs = startEntry && endEntry ? endEntry.startTime - startEntry.startTime : undefined;
this.devLog(
`phase_complete: ${scenario}.${phase} | ${
durationMs !== null && durationMs !== undefined ? `${Math.round(durationMs)}ms` : "?"
} | ${ctx.completed.size}/${ctx.config.requiredPhases.length} phases`,
);
traceSuccess(Action.MetricsScenario, {
event: "phase_complete",
scenario,
@@ -189,13 +149,6 @@ class ScenarioMonitor {
return;
}
// If an expected failure was flagged (auth, firewall, etc.), treat as success.
if (ctx.hasExpectedFailure) {
this.devLog(`phase_fail: ${scenario}.${phase} — expected failure, completing as healthy`);
this.completePhase(scenario, phase);
return;
}
// Mark the explicitly failed phase
performance.mark(`scenario_${scenario}_${phase}_failed`);
ctx.failed.add(phase);
@@ -210,12 +163,6 @@ class ScenarioMonitor {
// Build a snapshot with failure info
const failureSnapshot = this.buildSnapshot(ctx, { final: false, timedOut: false });
this.devLog(
`phase_fail: ${scenario}.${phase} | failed=[${Array.from(ctx.failed).join(", ")}] | completed=[${Array.from(
ctx.completed,
).join(", ")}]`,
);
traceFailure(Action.MetricsScenario, {
event: "phase_fail",
scenario,
@@ -224,28 +171,10 @@ class ScenarioMonitor {
completedPhases: Array.from(ctx.completed).join(","),
});
// Emit unhealthy immediately for unexpected failures
// Emit unhealthy immediately
this.emit(ctx, false, false, failureSnapshot);
}
/**
* Marks that an expected failure occurred (auth, firewall, permissions, etc.).
* When the scenario times out with this flag set, it will emit healthy instead of unhealthy.
* This is called automatically from handleError when an expected error is detected.
*/
markExpectedFailure() {
// Set the flag on all active (non-emitted) scenarios
this.contexts.forEach((ctx) => {
if (!ctx.emitted) {
ctx.hasExpectedFailure = true;
traceMark(Action.MetricsScenario, {
event: "expected_failure_marked",
scenario: ctx.scenario,
});
}
});
}
private tryEmitIfReady(ctx: InternalScenarioContext) {
const allDone = ctx.config.requiredPhases.every((p) => ctx.completed.has(p));
if (!allDone) {
@@ -305,7 +234,6 @@ class ScenarioMonitor {
scenario: ctx.scenario,
healthy,
timedOut,
documentHidden: document.hidden,
platform,
api,
durationMs: finalSnapshot.durationMs,
@@ -318,22 +246,8 @@ class ScenarioMonitor {
ttfb: finalSnapshot.vitals?.ttfb,
});
this.devLog(
`scenario_end: ${ctx.scenario} | ${healthy ? "healthy" : "unhealthy"} | ${
timedOut ? "timed out" : `${Math.round(finalSnapshot.durationMs)}ms`
} | ${JSON.stringify({
completedPhases: finalSnapshot.completed.join(", "),
failedPhases: finalSnapshot.failedPhases?.join(", ") || "none",
platform,
api,
phaseTimings: finalSnapshot.phaseTimings,
vitals: finalSnapshot.vitals,
})}`,
);
// Call portal backend health metrics endpoint
// If healthy is true (either completed successfully or timeout with expected failure), report healthy
if (healthy) {
if (healthy && !timedOut) {
reportHealthy(ctx.scenario, platform, api);
} else {
reportUnhealthy(ctx.scenario, platform, api);
@@ -388,19 +302,6 @@ class ScenarioMonitor {
phaseTimings,
};
}
/**
* Reset all scenarios (for testing purposes only).
* Clears all active contexts and their timeouts.
*/
reset() {
this.contexts.forEach((ctx) => {
if (ctx.timeoutId) {
clearTimeout(ctx.timeoutId);
}
});
this.contexts.clear();
}
}
export const scenarioMonitor = new ScenarioMonitor();

View File

@@ -1,26 +1,20 @@
import React from "react";
import MetricScenario from "./MetricEvents";
import { scenarioMonitor } from "./ScenarioMonitor";
import { useMetricScenario } from "./MetricScenarioProvider";
import { ApplicationMetricPhase, CommonMetricPhase } from "./ScenarioConfig";
/**
* Hook to automatically complete the Interactive phase when the component becomes interactive.
* Uses requestAnimationFrame to complete after the browser has painted.
*
* Calls scenarioMonitor directly (not via React context) so that the effect dependencies
* are only [scenario, enabled] — both stable primitives. This prevents re-renders from
* cancelling the pending rAF due to an unstable context function reference.
*/
export function useInteractive(scenario: MetricScenario, enabled = true) {
export function useInteractive(scenario: MetricScenario) {
const { completePhase } = useMetricScenario();
React.useEffect(() => {
if (!enabled) {
return undefined;
}
const id = requestAnimationFrame(() => {
scenarioMonitor.completePhase(scenario, CommonMetricPhase.Interactive);
requestAnimationFrame(() => {
completePhase(scenario, CommonMetricPhase.Interactive);
});
return () => cancelAnimationFrame(id);
}, [scenario, enabled]);
}, [scenario, completePhase]);
}
/**
@@ -28,20 +22,18 @@ export function useInteractive(scenario: MetricScenario, enabled = true) {
* Tracks tree rendering and completes Interactive phase.
* Only completes DatabaseTreeRendered if the database fetch was successful.
* Note: Scenario must be started before databases are fetched (in refreshExplorer).
*
* Calls scenarioMonitor directly (not via React context) for the same stability reason
* as useInteractive — avoids effect re-runs from unstable context function references.
*/
export function useDatabaseLoadScenario(databaseTreeNodes: unknown[], fetchSucceeded: boolean) {
const { completePhase } = useMetricScenario();
const hasCompletedTreeRenderRef = React.useRef(false);
// Track DatabaseTreeRendered phase (only if fetch succeeded)
React.useEffect(() => {
if (!hasCompletedTreeRenderRef.current && fetchSucceeded) {
hasCompletedTreeRenderRef.current = true;
scenarioMonitor.completePhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabaseTreeRendered);
completePhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabaseTreeRendered);
}
}, [databaseTreeNodes, fetchSucceeded]);
}, [databaseTreeNodes, fetchSucceeded, completePhase]);
// Track Interactive phase
useInteractive(MetricScenario.DatabaseLoad);

View File

@@ -0,0 +1,88 @@
import { initializeIcons } from "@fluentui/react";
import "bootstrap/dist/css/bootstrap.css";
import React from "react";
import * as ReactDOM from "react-dom";
import { configContext, initializeConfiguration } from "../ConfigContext";
import { GalleryHeaderComponent } from "../Explorer/Controls/Header/GalleryHeaderComponent";
import { GalleryTab } from "../Explorer/Controls/NotebookGallery/GalleryViewerComponent";
import {
NotebookViewerComponent,
NotebookViewerComponentProps,
} from "../Explorer/Controls/NotebookViewer/NotebookViewerComponent";
import * as FileSystemUtil from "../Explorer/Notebook/FileSystemUtil";
import { IGalleryItem, JunoClient } from "../Juno/JunoClient";
import * as GalleryUtils from "../Utils/GalleryUtils";
const onInit = async () => {
initializeIcons();
await initializeConfiguration();
const galleryViewerProps = GalleryUtils.getGalleryViewerProps(window.location.search);
const notebookViewerProps = GalleryUtils.getNotebookViewerProps(window.location.search);
let backNavigationText: string;
let onBackClick: () => void;
if (galleryViewerProps.selectedTab !== undefined) {
backNavigationText = GalleryUtils.getTabTitle(galleryViewerProps.selectedTab);
onBackClick = () =>
(window.location.href = `${configContext.hostedExplorerURL}gallery.html?tab=${
GalleryTab[galleryViewerProps.selectedTab]
}`);
}
const hideInputs = notebookViewerProps.hideInputs;
const notebookUrl = decodeURIComponent(notebookViewerProps.notebookUrl);
const galleryItemId = notebookViewerProps.galleryItemId;
let galleryItem: IGalleryItem;
if (galleryItemId) {
const junoClient = new JunoClient();
const galleryItemJunoResponse = await junoClient.getNotebookInfo(galleryItemId);
galleryItem = galleryItemJunoResponse.data;
}
// The main purpose of hiding the prompt is to hide everything when hiding inputs.
// It is generally not very useful to just hide the prompt.
const hidePrompts = hideInputs;
render(notebookUrl, backNavigationText, hideInputs, hidePrompts, galleryItem, onBackClick);
};
const render = (
notebookUrl: string,
backNavigationText: string,
hideInputs?: boolean,
hidePrompts?: boolean,
galleryItem?: IGalleryItem,
onBackClick?: () => void,
) => {
const props: NotebookViewerComponentProps = {
junoClient: galleryItem ? new JunoClient() : undefined,
notebookUrl,
galleryItem,
backNavigationText,
hideInputs,
hidePrompts,
onBackClick: onBackClick,
onTagClick: undefined,
};
if (galleryItem) {
document.title = FileSystemUtil.stripExtension(galleryItem.name, "ipynb");
}
const element = (
<>
<header>
<GalleryHeaderComponent />
</header>
<div style={{ marginLeft: 120, marginRight: 120 }}>
<NotebookViewerComponent {...props} />
</div>
</>
);
ReactDOM.render(element, document.getElementById("notebookContent"));
};
// Entry point
window.addEventListener("load", onInit);

View File

@@ -37,7 +37,7 @@ const requestFabricToken = async (): Promise<void> => {
scheduleRefreshFabricToken();
} catch (error) {
logConsoleError(error instanceof Error ? error.message : String(error));
logConsoleError(error as string);
throw error;
} finally {
lastRequestTimestamp = undefined;
@@ -105,12 +105,6 @@ const requestAndStoreAccessToken = async (): Promise<void> => {
});
};
export const openRestoreContainerDialog = (): void => {
if (isFabricNative()) {
sendCachedDataMessage(FabricMessageTypes.RestoreContainer, []);
}
};
/**
* Check token validity and schedule a refresh if necessary
* @param tokenTimestamp

View File

@@ -40,7 +40,6 @@ export type Features = {
readonly disableConnectionStringLogin: boolean;
readonly enableContainerCopy: boolean;
readonly enableCloudShell: boolean;
readonly enableRestoreContainer: boolean; // only for Fabric
// can be set via both flight and feature flag
autoscaleDefault: boolean;
@@ -94,7 +93,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear
notebookBasePath: get("notebookbasepath"),
notebookServerToken: get("notebookservertoken"),
notebookServerUrl: get("notebookserverurl"),
sandboxNotebookOutputs: true,
sandboxNotebookOutputs: "true" === get("sandboxnotebookoutputs", "true"),
selfServeType: get("selfservetype"),
showMinRUSurvey: "true" === get("showminrusurvey"),
ttl90Days: "true" === get("ttl90days"),
@@ -112,7 +111,6 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear
enablePriorityBasedExecution: "true" === get("enableprioritybasedexecution"),
disableConnectionStringLogin: "true" === get("disableconnectionstringlogin"),
enableContainerCopy: "true" === get("enablecontainercopy"),
enableRestoreContainer: "true" === get("enablerestorecontainer"),
enableCloudShell: true,
};
}

View File

@@ -9,6 +9,7 @@ import {
Stack,
Text,
} from "@fluentui/react";
import { TFunction } from "i18next";
import promiseRetry, { AbortError, Options } from "p-retry";
import React from "react";
import { WithTranslation } from "react-i18next";
@@ -80,7 +81,7 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
private smartUiGeneratorClassName: string;
private retryIntervalInMs: number;
private retryOptions: Options;
private translationFunction: (key: string) => string;
private translationFunction: TFunction;
componentDidMount(): void {
this.performRefresh().then(() => {
@@ -118,7 +119,7 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
this.retryOptions = { forever: true, maxTimeout: this.retryIntervalInMs, minTimeout: this.retryIntervalInMs };
// translation function passed to SelfServeComponent
this.translationFunction = this.props.t as (key: string) => string;
this.translationFunction = this.props.t;
}
private onError = (hasErrors: boolean): void => {

View File

@@ -27,7 +27,7 @@ describe("AuthorizationUtils", () => {
enableKoResourceTree: false,
enableThroughputBuckets: false,
hostedDataExplorer: false,
sandboxNotebookOutputs: true,
sandboxNotebookOutputs: false,
showMinRUSurvey: false,
ttl90Days: false,
enableThroughputCap: false,
@@ -43,7 +43,6 @@ describe("AuthorizationUtils", () => {
partitionKeyDefault: false,
partitionKeyDefault2: false,
notebooksDownBanner: false,
enableRestoreContainer: false,
},
});
};

View File

@@ -8,8 +8,6 @@ import * as Logger from "../Common/Logger";
import { configContext } from "../ConfigContext";
import { DatabaseAccount } from "../Contracts/DataModels";
import * as ViewModels from "../Contracts/ViewModels";
import { isExpectedError } from "../Metrics/ErrorClassification";
import { scenarioMonitor } from "../Metrics/ScenarioMonitor";
import { trace, traceFailure } from "../Shared/Telemetry/TelemetryProcessor";
import { UserContext, userContext } from "../UserContext";
@@ -129,10 +127,6 @@ export async function acquireMsalTokenForAccount(
acquireTokenType: silent ? "silent" : "interactive",
errorMessage: JSON.stringify(error),
});
// Mark expected failure for health metrics so timeout emits healthy
if (isExpectedError(error)) {
scenarioMonitor.markExpectedFailure();
}
throw error;
}
} else {
@@ -175,10 +169,7 @@ export async function acquireTokenWithMsal(
acquireTokenType: "interactive",
errorMessage: JSON.stringify(interactiveError),
});
// Mark expected failure for health metrics so timeout emits healthy
if (isExpectedError(interactiveError)) {
scenarioMonitor.markExpectedFailure();
}
throw interactiveError;
}
} else {
@@ -187,10 +178,7 @@ export async function acquireTokenWithMsal(
acquireTokenType: "silent",
errorMessage: JSON.stringify(silentError),
});
// Mark expected failure for health metrics so timeout emits healthy
if (isExpectedError(silentError)) {
scenarioMonitor.markExpectedFailure();
}
throw silentError;
}
}

View File

@@ -4,12 +4,7 @@ import * as sinon from "sinon";
import * as DataModels from "../Contracts/DataModels";
import * as ViewModels from "../Contracts/ViewModels";
import * as QueryUtils from "./QueryUtils";
import {
defaultQueryFields,
extractPartitionKeyValues,
getValueForPath,
stripDoubleQuotesFromSegment,
} from "./QueryUtils";
import { defaultQueryFields, extractPartitionKeyValues, getValueForPath } from "./QueryUtils";
const documentContent = {
"Volcano Name": "Adams",
@@ -284,97 +279,5 @@ describe("Query Utils", () => {
expect(partitionKeyValues.length).toBe(2);
expect(partitionKeyValues).toEqual([null, {}]);
});
it("should extract partition key value when path has enclosing double quotes", () => {
const docWithSpecialKey = {
id: "test-id",
"partition-key": "some-value",
};
const partitionKeyDefinition: PartitionKeyDefinition = {
kind: PartitionKeyKind.Hash,
paths: ['/"partition-key"'],
};
const partitionKeyValues: PartitionKey[] = extractPartitionKeyValues(docWithSpecialKey, partitionKeyDefinition);
expect(partitionKeyValues.length).toBe(1);
expect(partitionKeyValues[0]).toEqual("some-value");
});
it("should extract nested partition key value when path segments have enclosing double quotes", () => {
const docWithSpecialKey = {
id: "test-id",
"my-field": {
"sub-field": 42,
},
};
const partitionKeyDefinition: PartitionKeyDefinition = {
kind: PartitionKeyKind.Hash,
paths: ['/"my-field"/"sub-field"'],
};
const partitionKeyValues: PartitionKey[] = extractPartitionKeyValues(docWithSpecialKey, partitionKeyDefinition);
expect(partitionKeyValues.length).toBe(1);
expect(partitionKeyValues[0]).toEqual(42);
});
it("should return {} for missing double-quoted partition key", () => {
const docWithSpecialKey = {
id: "test-id",
};
const partitionKeyDefinition: PartitionKeyDefinition = {
kind: PartitionKeyKind.Hash,
paths: ['/"partition-key"'],
};
const partitionKeyValues: PartitionKey[] = extractPartitionKeyValues(docWithSpecialKey, partitionKeyDefinition);
expect(partitionKeyValues.length).toBe(1);
expect(partitionKeyValues[0]).toEqual({});
});
it("should handle multi-hash with mixed quoted and unquoted paths", () => {
const doc = {
id: "test-id",
Country: "Japan",
"partition-key": "hello",
};
const partitionKeyDefinition: PartitionKeyDefinition = {
kind: PartitionKeyKind.MultiHash,
paths: ["/Country", '/"partition-key"'],
};
const partitionKeyValues: PartitionKey[] = extractPartitionKeyValues(doc, partitionKeyDefinition);
expect(partitionKeyValues.length).toBe(2);
expect(partitionKeyValues).toEqual(["Japan", "hello"]);
});
});
describe("stripDoubleQuotesFromSegment", () => {
it("should strip enclosing double quotes", () => {
expect(stripDoubleQuotesFromSegment('"partition-key"')).toBe("partition-key");
});
it("should not strip if only opening quote", () => {
expect(stripDoubleQuotesFromSegment('"partition-key')).toBe('"partition-key');
});
it("should not strip if only closing quote", () => {
expect(stripDoubleQuotesFromSegment('partition-key"')).toBe('partition-key"');
});
it("should return empty string when stripping quotes from empty quoted string", () => {
expect(stripDoubleQuotesFromSegment('""')).toBe("");
});
it("should not modify unquoted segments", () => {
expect(stripDoubleQuotesFromSegment("Country")).toBe("Country");
});
it("should not strip single quotes", () => {
expect(stripDoubleQuotesFromSegment("'partition-key'")).toBe("'partition-key'");
});
});
});

View File

@@ -116,17 +116,6 @@ export const queryPagesUntilContentPresent = async (
return await doRequest(firstItemIndex);
};
/**
* Strips enclosing double quotes from a partition key path segment.
* e.g., '"partition-key"' -> 'partition-key'
*/
export const stripDoubleQuotesFromSegment = (segment: string): string => {
if (segment.length >= 2 && segment.charAt(0) === '"' && segment.charAt(segment.length - 1) === '"') {
return segment.slice(1, -1);
}
return segment;
};
/* eslint-disable @typescript-eslint/no-explicit-any */
export const getValueForPath = (content: any, pathSegments: string[]): any => {
if (pathSegments.length === 0) {
@@ -157,7 +146,7 @@ export const extractPartitionKeyValues = (
const partitionKeyValues: PartitionKey[] = [];
partitionKeyDefinition.paths.forEach((partitionKeyPath: string) => {
const pathSegments: string[] = partitionKeyPath.substring(1).split("/").map(stripDoubleQuotesFromSegment);
const pathSegments: string[] = partitionKeyPath.substring(1).split("/");
const value = getValueForPath(documentContent, pathSegments);
if (value !== undefined) {

View File

@@ -1,21 +1,16 @@
import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import resourcesToBackend from "i18next-resources-to-backend";
import { initReactI18next } from "react-i18next";
i18n
.use(LanguageDetector)
.use(resourcesToBackend((lng: string, ns: string) => import(`./Localization/${lng}/${ns}.json`)))
.use(initReactI18next)
.init({
fallbackLng: "en",
defaultNS: "Resources",
ns: ["Resources"],
detection: { order: ["navigator", "cookie", "localStorage", "sessionStorage", "querystring", "htmlTag"] },
debug: process.env.NODE_ENV === "development",
keySeparator: ".",
interpolation: {
escapeValue: false,
formatSeparator: ",",
},
react: {

View File

@@ -2,54 +2,20 @@ import { Page } from "@playwright/test";
export async function setupCORSBypass(page: Page) {
await page.route("**/api/mongo/explorer{,/**}", async (route) => {
const request = route.request();
const origin = request.headers()["origin"];
// If there's no origin, it's not a CORS request. Let it proceed without modification.
if (!origin) {
await route.continue();
return;
}
//// Handle preflight (OPTIONS) requests separately.
// These should not be forwarded to the target server.
if (request.method() === "OPTIONS") {
await route.fulfill({
status: 204, // No Content
headers: {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS,HEAD",
"Access-Control-Request-Headers": "*, x-ms-continuation",
"Access-Control-Max-Age": "86400", // Cache preflight response for 1 day
Vary: "Origin",
},
});
return;
}
// Handle the actual GET/POST request
const response = await route.fetch({
headers: {
...request.headers(),
...route.request().headers(),
},
});
const responseHeaders = response.headers();
// Clean up any pre-existing CORS headers from the real response to avoid conflicts.
delete responseHeaders["access-control-allow-origin"];
delete responseHeaders["access-control-allow-credentials"];
await route.fulfill({
status: response.status(),
headers: {
...responseHeaders,
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS,HEAD",
...response.headers(),
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "*",
"Access-Control-Allow-Headers": "*",
"Access-Control-Expose-Headers": "x-ms-continuation,x-ms-request-charge,x-ms-session-token",
Vary: "Origin",
"Access-Control-Allow-Credentials": "*",
},
body: await response.body(),
});

163
test/E2E-SETUP.md Normal file
View File

@@ -0,0 +1,163 @@
# E2E Testing Setup for Cosmos DB Explorer
This document describes the pre-flight setup process for running end-to-end tests in the Cosmos DB Explorer project.
## Overview
The testing setup includes:
1. Azure CLI authentication
2. Subscription selection
3. Test account configuration
4. Token generation for NoSQL container operations
5. Browser storage state setup for Playwright
## Prerequisites
Before running tests, ensure you have:
- [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli) installed and updated
- PowerShell execution policy set to allow script execution
- Access to the Azure subscription: `074d02eb-4d74-486a-b299-b262264d1536`
- Access to the resource group: `bchoudhury-e2e-testing`
- Required Azure resources with prefix: `bchoudhury-e2e-`
## Automatic Setup (Recommended)
The setup runs automatically when you start Playwright tests:
```bash
npm run test:e2e
```
or
```bash
npx playwright test
```
The global setup script (`test/global-setup.ts`) will:
1. Check if you're logged in to Azure CLI
2. If not logged in, prompt for authentication
3. Set the correct subscription
4. Run the PowerShell configuration script
5. Generate required access tokens
6. Set up browser authentication state
## Manual Setup
If you need to run the setup independently:
```bash
npm run test:e2e:setup
```
or directly:
```bash
node setup-tests.js
```
## Manual Step-by-Step Setup
If you prefer to run each step manually:
### 1. Azure CLI Login
```bash
az login --scope https://management.core.windows.net//.default
```
### 2. Set Subscription
```bash
az account set --subscription "074d02eb-4d74-486a-b299-b262264d1536"
```
### 3. Configure Test Accounts
```powershell
.\test\scripts\set-test-accounts.ps1 -ResourceGroup "bchoudhury-e2e-testing" -Subscription "074d02eb-4d74-486a-b299-b262264d1536" -ResourcePrefix "bchoudhury-e2e-"
```
### 4. Set NoSQL Container Token
```bash
$ENV:NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://bchoudhury-e2e-sqlcontainercopyonly.documents.azure.com/.default" -o tsv --query accessToken)
```
## Environment Variables
The setup process configures these environment variables:
- `DE_TEST_RESOURCE_GROUP`: The Azure resource group for testing
- `DE_TEST_SUBSCRIPTION_ID`: The Azure subscription ID
- `DE_TEST_ACCOUNT_PREFIX`: The prefix used for test resources
- `NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN`: Access token for NoSQL operations
## Storage State
Browser authentication state is saved to:
```
playwright/.auth/user.json
```
This file is automatically generated and excluded from version control.
## Troubleshooting
### Common Issues
1. **Azure CLI not found**
- Install Azure CLI: https://docs.microsoft.com/en-us/cli/azure/install-azure-cli
2. **PowerShell execution policy error**
```powershell
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
```
3. **Authentication expired**
```bash
az logout
az login --scope https://management.core.windows.net//.default
```
4. **Resource not found**
- Verify you have access to the subscription and resource group
- Check that resources exist with the expected prefix
### Debug Mode
For verbose output during setup, you can run:
```bash
DEBUG=1 npm run test:e2e:setup
```
## File Structure
```
test/
├── global-setup.ts # Main global setup script
├── scripts/
│ ├── set-test-accounts.ps1 # PowerShell test account setup
│ ├── check-test-accounts.ps1
│ └── clean-test-accounts.ps1
└── ...
playwright/
└── .auth/
└── user.json # Browser storage state (generated)
setup-tests.js # Manual setup runner
playwright.config.ts # Playwright config with global setup
```
## Configuration
The setup is configured in:
- `playwright.config.ts`: Playwright configuration with global setup
- `test/global-setup.ts`: Main setup logic
- `setup-tests.js`: Manual setup runner
## Notes
- The setup only needs to run once per session
- Browser storage state persists between test runs
- Azure tokens are refreshed automatically by the Azure CLI
- The setup is idempotent - safe to run multiple times

View File

@@ -58,9 +58,7 @@ export const defaultAccounts: Record<TestAccount, string> = {
export const resourceGroupName = process.env.DE_TEST_RESOURCE_GROUP ?? "de-e2e-tests";
export const subscriptionId = process.env.DE_TEST_SUBSCRIPTION_ID ?? "69e02f2d-f059-4409-9eac-97e8a276ae2c";
export const TEST_AUTOSCALE_THROUGHPUT_RU = 1000;
export const TEST_MANUAL_THROUGHPUT_RU = 800;
export const TEST_AUTOSCALE_MAX_THROUGHPUT_RU_2K = 2000;
export const TEST_AUTOSCALE_MAX_THROUGHPUT_RU_4K = 4000;
export const TEST_MANUAL_THROUGHPUT_RU_2K = 2000;
export const ONE_MINUTE_MS: number = 60 * 1000;
@@ -250,7 +248,7 @@ class TreeNode {
// Try three times to wait for the node to expand.
for (let i = 0; i < RETRY_COUNT; i++) {
try {
await tree.waitFor({ state: "visible", timeout: 30000 });
await tree.waitFor({ state: "visible" });
// The tree has expanded, let's get out of here
return true;
} catch {
@@ -380,11 +378,9 @@ type PanelOpenOptions = {
export enum CommandBarButton {
Save = "Save",
Delete = "Delete",
Execute = "Execute",
ExecuteQuery = "Execute Query",
UploadItem = "Upload Item",
NewDocument = "New Document",
}
/** Helper class that provides locator methods for DataExplorer components, on top of a Frame */
@@ -482,7 +478,7 @@ export class DataExplorer {
return await this.waitForNode(`${databaseId}/${containerId}/Documents`);
}
async waitForCommandBarButton(label: CommandBarButton, timeout?: number): Promise<Locator> {
async waitForCommandBarButton(label: string, timeout?: number): Promise<Locator> {
const commandBar = this.commandBarButton(label);
await commandBar.waitFor({ state: "visible", timeout });
return commandBar;
@@ -519,6 +515,15 @@ export class DataExplorer {
const containerNode = await this.waitForContainerNode(context.database.id, context.container.id);
await containerNode.expand();
// refresh tree to remove deleted database
const consoleMessages = await this.getNotificationConsoleMessages();
const refreshButton = this.frame.getByTestId("Sidebar/RefreshButton");
await refreshButton.click();
await expect(consoleMessages).toContainText("Successfully refreshed databases", {
timeout: ONE_MINUTE_MS,
});
await this.collapseNotificationConsole();
const scaleAndSettingsButton = this.frame.getByTestId(
`TreeNode:${context.database.id}/${context.container.id}/Scale & Settings`,
);
@@ -696,6 +701,7 @@ export class ContainerCopy {
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<ContainerCopy> {
const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true });
// console.log(`Navigating to URL: ${url}`);
await page.goto(url);
return ContainerCopy.waitForContainerCopy(page);
}

145
test/global-setup copy.js Normal file
View File

@@ -0,0 +1,145 @@
const { chromium } = require('@playwright/test');
const { exec } = require('child_process');
const fs = require('fs').promises;
const path = require('path');
const { promisify } = require('util');
const execAsync = promisify(exec);
async function globalSetup(config) {
console.log('🚀 Starting global setup for Azure Cosmos DB Explorer tests...');
try {
// Step 1: Check if user is already logged in to Azure CLI
console.log('📋 Checking Azure CLI login status...');
try {
const { stdout: accountInfo } = await execAsync('az account show --output json');
const account = JSON.parse(accountInfo);
console.log(`✅ Already logged in as: ${account.user?.name} in subscription: ${account.name}`);
} catch (error) {
console.log('🔐 Not logged in to Azure CLI, initiating login...');
// Step 2: Login to Azure with specific scope
console.log('🔑 Logging in to Azure CLI...');
console.log('Please complete the authentication in your browser...');
await execAsync('az login --scope https://management.core.windows.net//.default');
console.log('✅ Azure CLI login completed');
}
// Step 3: Set the subscription
const targetSubscription = '074d02eb-4d74-486a-b299-b262264d1536';
console.log(`🔄 Setting subscription to: ${targetSubscription}`);
await execAsync(`az account set --subscription "${targetSubscription}"`);
console.log('✅ Subscription set successfully');
// Step 4: Run the PowerShell test account setup script
console.log('⚙️ Running test account setup script...');
const scriptPath = path.join(__dirname, 'scripts', 'set-test-accounts.ps1');
const resourceGroup = 'bchoudhury-e2e-testing';
const resourcePrefix = 'bchoudhury-e2e-';
// Try PowerShell 7 first, fallback to Windows PowerShell if not available
let psCommand = `pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "${scriptPath}" -ResourceGroup "${resourceGroup}" -Subscription "${targetSubscription}" -ResourcePrefix "${resourcePrefix}"`;
try {
const { stdout, stderr } = await execAsync(psCommand);
if (stdout) console.log('PowerShell Output:', stdout);
if (stderr) console.log('PowerShell Warnings:', stderr);
console.log('✅ Test account setup completed');
} catch (psError) {
console.log('🔄 PowerShell 7 not available, trying Windows PowerShell...');
// Fallback to Windows PowerShell with NoProfile to avoid module loading issues
const fallbackCommand = `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${scriptPath}" -ResourceGroup "${resourceGroup}" -Subscription "${targetSubscription}" -ResourcePrefix "${resourcePrefix}"`;
try {
const { stdout: fbStdout, stderr: fbStderr } = await execAsync(fallbackCommand);
if (fbStdout) console.log('PowerShell Output:', fbStdout);
if (fbStderr) console.log('PowerShell Warnings:', fbStderr);
console.log('✅ Test account setup completed');
} catch (fallbackError) {
console.error('❌ PowerShell script execution failed:', fallbackError.message);
console.warn('⚠️ The PowerShell script failed. This might be due to:');
console.warn(' 1. PowerShell version compatibility (script requires PowerShell 7+ for ?? operator)');
console.warn(' 2. Missing Azure PowerShell modules');
console.warn(' 3. Insufficient permissions or missing resources');
// Don't throw here as the script might have partial success
}
}
// Step 5: Set the NoSQL container copy test account token
console.log('🎟️ Setting up NoSQL container copy test account token...');
try {
const { stdout: tokenOutput } = await execAsync(
'az account get-access-token --scope "https://bchoudhury-e2e-sqlcontainercopyonly.documents.azure.com/.default" -o tsv --query accessToken'
);
const token = tokenOutput.trim();
if (token) {
process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN = token;
console.log('✅ NoSQL container copy token set successfully');
} else {
console.warn('⚠️ Failed to retrieve NoSQL container copy token');
}
} catch (tokenError) {
console.error('❌ Failed to set NoSQL token:', tokenError.message);
// Continue without throwing as this might not be critical for all tests
}
// Step 6: Create browser context and save storage state for authentication
console.log('🌐 Setting up browser authentication state...');
const browser = await chromium.launch();
const context = await browser.newContext({
ignoreHTTPSErrors: true,
});
const page = await context.newPage();
// Navigate to a login page or perform any authentication steps if needed
// For now, we'll just create an empty storage state that can be populated later
// Save the storage state
const storageStatePath = path.join(__dirname, '../playwright/.auth/user.json');
await fs.mkdir(path.dirname(storageStatePath), { recursive: true });
await context.storageState({ path: storageStatePath });
await browser.close();
console.log(`✅ Browser storage state saved to: ${storageStatePath}`);
// Step 7: Set environment variables that tests might need
console.log('📝 Setting up environment variables for tests...');
// Verify required environment variables are set
const requiredEnvVars = [
'DE_TEST_RESOURCE_GROUP',
'DE_TEST_SUBSCRIPTION_ID',
'DE_TEST_ACCOUNT_PREFIX'
];
const missingVars = requiredEnvVars.filter(varName => !process.env[varName]);
if (missingVars.length > 0) {
console.warn(`⚠️ Missing environment variables: ${missingVars.join(', ')}`);
console.warn('These should be set by the PowerShell script. Tests may fail.');
} else {
console.log('✅ All required environment variables are set');
}
console.log('🎉 Global setup completed successfully!');
console.log('\n📊 Setup Summary:');
console.log(` - Subscription: ${targetSubscription}`);
console.log(` - Resource Group: ${resourceGroup}`);
console.log(` - Resource Prefix: ${resourcePrefix}`);
console.log(` - Storage State: ${storageStatePath}`);
} catch (error) {
console.error('❌ Global setup failed:', error.message);
console.error('Stack trace:', error.stack);
throw error;
}
}
module.exports = globalSetup;

135
test/global-setup.ts Normal file
View File

@@ -0,0 +1,135 @@
import { type FullConfig } from '@playwright/test';
import { exec } from 'child_process';
import path from 'path';
import { promisify } from 'util';
const execAsync = promisify(exec);
export default async function globalSetup(
_config?: FullConfig
): Promise<void> {
console.log('🚀 Starting global setup for Azure Cosmos DB Explorer tests...');
try {
// ------------------------------------------------------------------
// Step 1: Azure CLI login
// ------------------------------------------------------------------
console.log('📋 Checking Azure CLI login status...');
try {
const { stdout } = await execAsync('az account show --output json');
const account = JSON.parse(stdout);
console.log(
`✅ Already logged in as: ${account.user?.name} in subscription: ${account.name}`
);
} catch {
console.log('🔐 Not logged in to Azure CLI, initiating login...');
await execAsync(
'az login --scope https://management.core.windows.net//.default'
);
console.log('✅ Azure CLI login completed');
}
// ------------------------------------------------------------------
// Step 2: Set subscription
// ------------------------------------------------------------------
const targetSubscription = '074d02eb-4d74-486a-b299-b262264d1536';
console.log(`🔄 Setting subscription to: ${targetSubscription}`);
await execAsync(
`az account set --subscription "${targetSubscription}"`
);
// ------------------------------------------------------------------
// Step 3: Run PowerShell test account setup (DOT-SOURCED)
// ------------------------------------------------------------------
console.log('⚙️ Running test account setup script...');
const scriptPath = path.join(
__dirname,
'scripts',
'set-test-accounts.ps1'
);
const resourceGroup = 'bchoudhury-e2e-testing';
const resourcePrefix = 'bchoudhury-e2e-';
// IMPORTANT:
// - Dot-source the PS1
// - Print env vars BEFORE PowerShell exits
const psScript = `
. "${scriptPath}" `
+ `-ResourceGroup "${resourceGroup}" `
+ `-Subscription "${targetSubscription}" `
+ `-ResourcePrefix "${resourcePrefix}"
Get-ChildItem Env:DE_TEST_* | ForEach-Object {
Write-Output "$($_.Name)=$($_.Value)"
}
`;
// PowerShell requires UTF-16LE encoding for -EncodedCommand
const encodedCommand = Buffer.from(psScript, 'utf16le').toString('base64');
const { stdout: psStdout, stderr: psStderr } = await execAsync(
`pwsh -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encodedCommand}`
);
if (psStderr) {
console.log('PowerShell warnings:', psStderr);
}
// ------------------------------------------------------------------
// Step 3a: Import env vars from PowerShell
// ------------------------------------------------------------------
psStdout
.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line.startsWith('DE_TEST_'))
.forEach(line => {
const [key, ...rest] = line.split('=');
process.env[key] = rest.join('=');
console.log(`✅ Imported env var from PS: ${key}`);
});
// ------------------------------------------------------------------
// Step 4: NoSQL access token
// ------------------------------------------------------------------
console.log('🎟️ Setting up NoSQL container copy test account token...');
try {
const { stdout } = await execAsync(
'az account get-access-token --scope "https://bchoudhury-e2e-sqlcontainercopyonly.documents.azure.com/.default" -o tsv --query accessToken'
);
const token = stdout.trim();
if (token) {
process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN = token;
console.log('✅ NoSQL container copy token set successfully');
}
} catch (err: any) {
console.error('❌ Failed to set NoSQL token:', err.message);
}
// Browser authentication not needed - using API tokens directly
// ------------------------------------------------------------------
// Step 6: Validate env vars
// ------------------------------------------------------------------
console.log('📝 Validating environment variables...');
const requiredEnvVars = [
'DE_TEST_RESOURCE_GROUP',
'DE_TEST_SUBSCRIPTION_ID',
'DE_TEST_ACCOUNT_PREFIX',
];
const missing = requiredEnvVars.filter(v => !process.env[v]);
if (missing.length > 0) {
console.warn(`⚠️ Missing environment variables: ${missing.join(', ')}`);
} else {
console.log('✅ All required environment variables are set');
}
console.log('🎉 Global setup completed successfully!');
} catch (error: any) {
console.error('❌ Global setup failed:', error.message);
console.error(error.stack);
throw error;
}
}

View File

@@ -1,7 +1,7 @@
import { expect, test } from "@playwright/test";
import { setupCORSBypass } from "../CORSBypass";
import { CommandBarButton, DataExplorer, DocumentsTab, TestAccount } from "../fx";
import { DataExplorer, DocumentsTab, TestAccount } from "../fx";
import { retry, serializeMongoToJson, setPartitionKeys } from "../testData";
import { documentTestCases } from "./testCases";
@@ -48,20 +48,19 @@ for (const { name, databaseId, containerId, documents } of documentTestCases) {
expect(resultData?._id).not.toBeNull();
expect(resultData?._id).toEqual(docId);
});
test(`should be able to create and delete new document from ${docId}`, async ({ page }) => {
test(`should be able to create and delete new document from ${docId}`, async () => {
const span = documentsTab.documentsListPane.getByText(docId, { exact: true }).nth(0);
await span.waitFor();
await expect(span).toBeVisible();
await span.click();
await page.waitForTimeout(5000); // wait for 5 seconds to ensure document is fully loaded. waitforTimeout is not recommended generally but here we are working around flakiness in the test env
let newDocumentId;
await retry(async () => {
const newDocumentButton = await explorer.waitForCommandBarButton(CommandBarButton.NewDocument, 5000);
const newDocumentButton = await explorer.waitForCommandBarButton("New Document", 5000);
await expect(newDocumentButton).toBeVisible();
await expect(newDocumentButton).toBeEnabled();
await newDocumentButton.click();
await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 });
newDocumentId = `${Date.now().toString()}-delete`;
@@ -72,9 +71,8 @@ for (const { name, databaseId, containerId, documents } of documentTestCases) {
};
await documentsTab.resultsEditor.setText(JSON.stringify(newDocument));
const saveButton = await explorer.waitForCommandBarButton(CommandBarButton.Save, 5000);
const saveButton = await explorer.waitForCommandBarButton("Save", 5000);
await saveButton.click({ timeout: 5000 });
await expect(saveButton).toBeHidden({ timeout: 5000 });
}, 3);
@@ -86,7 +84,7 @@ for (const { name, databaseId, containerId, documents } of documentTestCases) {
await newSpan.click();
await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 });
const deleteButton = await explorer.waitForCommandBarButton(CommandBarButton.Delete, 5000);
const deleteButton = await explorer.waitForCommandBarButton("Delete", 5000);
await deleteButton.click();
const deleteDialogButton = await explorer.waitForDialogButton("Delete", 5000);

View File

@@ -1,132 +0,0 @@
import { expect, test } from "@playwright/test";
import { setupCORSBypass } from "../CORSBypass";
import { DataExplorer, QueryTab, TestAccount, CommandBarButton, Editor } from "../fx";
import { serializeMongoToJson } from "../testData";
const databaseId = "test-e2etests-mongo-pagination";
const collectionId = "test-coll-mongo-pagination";
let explorer: DataExplorer = null!;
test.setTimeout(5 * 60 * 1000);
test.describe("Test Mongo Pagination", () => {
let queryTab: QueryTab;
let queryEditor: Editor;
test.beforeEach("Open query tab", async ({ page }) => {
await setupCORSBypass(page);
explorer = await DataExplorer.open(page, TestAccount.MongoReadonly);
const containerNode = await explorer.waitForContainerNode(databaseId, collectionId);
await containerNode.expand();
const containerMenuNode = await explorer.waitForContainerDocumentsNode(databaseId, collectionId);
await containerMenuNode.openContextMenu();
await containerMenuNode.contextMenuItem("New Query").click();
queryTab = explorer.queryTab("tab0");
queryEditor = queryTab.editor();
await queryEditor.locator.waitFor({ timeout: 30 * 1000 });
await queryTab.executeCTA.waitFor();
await explorer.frame.getByTestId("NotificationConsole/ExpandCollapseButton").click();
await explorer.frame.getByTestId("NotificationConsole/Contents").waitFor();
});
test("should execute a query and load more results", async ({ page }) => {
const query = "{}";
await queryEditor.locator.click();
await queryEditor.setText(query);
const executeQueryButton = explorer.commandBarButton(CommandBarButton.ExecuteQuery);
await executeQueryButton.click();
// Wait for query execution to complete
await expect(queryTab.resultsView).toBeVisible({ timeout: 60000 });
await expect(queryTab.resultsEditor.locator).toBeAttached({ timeout: 30000 });
// Get initial results
const resultText = await queryTab.resultsEditor.text();
if (!resultText || resultText.trim() === "" || resultText.trim() === "[]") {
throw new Error("Query returned no results - the collection appears to be empty");
}
const resultData = serializeMongoToJson(resultText);
if (resultData.length === 0) {
throw new Error("Parsed results contain 0 documents - collection is empty");
}
if (resultData.length < 100) {
expect(resultData.length).toBeGreaterThan(0);
return;
}
expect(resultData.length).toBe(100);
// Pagination test
let totalPagesLoaded = 1;
const maxLoadMoreAttempts = 10;
for (let loadMoreAttempts = 0; loadMoreAttempts < maxLoadMoreAttempts; loadMoreAttempts++) {
const loadMoreButton = queryTab.resultsView.getByText("Load more");
try {
await expect(loadMoreButton).toBeVisible({ timeout: 5000 });
} catch {
// Load more button not visible - pagination complete
break;
}
const beforeClickText = await queryTab.resultsEditor.text();
const beforeClickHash = Buffer.from(beforeClickText || "")
.toString("base64")
.substring(0, 50);
await loadMoreButton.click();
// Wait for content to update
let editorContentChanged = false;
for (let waitAttempt = 1; waitAttempt <= 3; waitAttempt++) {
await page.waitForTimeout(2000);
const currentEditorText = await queryTab.resultsEditor.text();
const currentHash = Buffer.from(currentEditorText || "")
.toString("base64")
.substring(0, 50);
if (currentHash !== beforeClickHash) {
editorContentChanged = true;
break;
}
}
if (editorContentChanged) {
totalPagesLoaded++;
} else {
// No content change detected, stop pagination
break;
}
await page.waitForTimeout(1000);
}
// Final verification
const finalIndicator = queryTab.resultsView.locator("text=/\\d+ - \\d+/");
const finalIndicatorText = await finalIndicator.textContent();
if (finalIndicatorText) {
const match = finalIndicatorText.match(/(\d+) - (\d+)/);
if (match) {
const totalDocuments = parseInt(match[2]);
expect(totalDocuments).toBe(405);
expect(totalPagesLoaded).toBe(5);
} else {
throw new Error(`Invalid results indicator format: ${finalIndicatorText}`);
}
} else {
expect(totalPagesLoaded).toBe(5);
}
});
});

View File

@@ -18,7 +18,10 @@ if (-not $Subscription) {
$Subscription = $currentSubscription.Id
}
$AzSubscription = (Get-AzSubscription -SubscriptionId $Subscription -ErrorAction SilentlyContinue | Select-Object -First 1) ?? (Get-AzSubscription -SubscriptionName $Subscription -ErrorAction SilentlyContinue | Select-Object -First 1)
$AzSubscription = (Get-AzSubscription -SubscriptionId $Subscription -ErrorAction SilentlyContinue | Select-Object -First 1)
if (-not $AzSubscription) {
$AzSubscription = (Get-AzSubscription -SubscriptionName $Subscription -ErrorAction SilentlyContinue | Select-Object -First 1)
}
if (-not $AzSubscription) {
throw "The subscription '$Subscription' could not be found."
}

7
test/seed.spec.ts Normal file
View File

@@ -0,0 +1,7 @@
import { test, expect } from '@playwright/test';
test.describe('Test group', () => {
test('seed', async ({ page }) => {
// generate code here.
});
});

View File

@@ -0,0 +1,51 @@
import { expect, test } from '@playwright/test';
test.describe('Azure Setup Verification', () => {
test('should have required environment variables set', async ({ page }) => {
// Verify that the global setup has properly configured the environment
const requiredEnvVars = [
'DE_TEST_RESOURCE_GROUP',
'DE_TEST_SUBSCRIPTION_ID',
'DE_TEST_ACCOUNT_PREFIX'
];
for (const envVar of requiredEnvVars) {
expect(process.env[envVar], `Environment variable ${envVar} should be set by global setup`).toBeDefined();
expect(process.env[envVar], `Environment variable ${envVar} should not be empty`).not.toBe('');
}
console.log('✅ Environment Variables:');
console.log(` DE_TEST_RESOURCE_GROUP: ${process.env.DE_TEST_RESOURCE_GROUP}`);
console.log(` DE_TEST_SUBSCRIPTION_ID: ${process.env.DE_TEST_SUBSCRIPTION_ID}`);
console.log(` DE_TEST_ACCOUNT_PREFIX: ${process.env.DE_TEST_ACCOUNT_PREFIX}`);
if (process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN) {
console.log(` NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN: [SET]`);
} else {
console.log(` NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN: [NOT SET]`);
}
});
/* test('should be able to navigate to the application', async ({ page }) => {
// This assumes the web server is running on https://127.0.0.1:1234
// as configured in playwright.config.ts
try {
await page.goto('https://127.0.0.1:1234');
// Wait for the page to load
await page.waitForTimeout(2000);
// Check if the page loaded successfully
const title = await page.title();
console.log(`Page title: ${title}`);
// This is a basic check - you might want to check for specific elements
// that indicate the Cosmos DB Explorer has loaded correctly
expect(title).toBeTruthy();
} catch (error) {
console.log('Note: Application server might not be running. Start it with: npm run start');
throw error;
}
}); */
});

View File

@@ -0,0 +1,505 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { expect, Frame, Locator, Page, test } from "@playwright/test";
import { set } from "lodash";
import { truncateName } from "../../src/Explorer/ContainerCopy/CopyJobUtils";
import {
ContainerCopy,
getAccountName,
getDropdownItemByNameOrPosition,
interceptAndInspectApiRequest,
TestAccount,
waitForApiResponse,
} from "../fx";
import { createMultipleTestContainers } from "../testData";
let page: Page;
let wrapper: Locator = null!;
let panel: Locator = null!;
let frame: Frame = null!;
let expectedCopyJobNameInitial: string = null!;
let expectedJobName: string = "";
let targetAccountName: string = "";
let expectedSourceAccountName: string = "";
let expectedSubscriptionName: string = "";
const VISIBLE_TIMEOUT_MS = 30 * 1000;
test.describe.configure({ mode: "serial" });
test.describe("Container Copy", () => {
test.beforeAll("Container Copy - Before All", async ({ browser }) => {
await createMultipleTestContainers({ accountType: TestAccount.SQLContainerCopyOnly, containerCount: 3 });
page = await browser.newPage();
({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly));
expectedJobName = `test_job_${Date.now()}`;
targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
});
test.afterEach("Container Copy - After Each", async () => {
await page.unroute(/.*/, (route) => route.continue());
});
test("Loading and verifying the content of the page", async () => {
expect(wrapper).not.toBeNull();
await expect(wrapper.getByTestId("CommandBar/Button:Create Copy Job")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
await expect(wrapper.getByTestId("CommandBar/Button:Refresh")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
await expect(wrapper.getByTestId("CommandBar/Button:Feedback")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
});
test("Successfully create a copy job for offline migration", async () => {
expect(wrapper).not.toBeNull();
// Loading and verifying subscription & account dropdown
const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job");
await createCopyJobButton.click();
panel = frame.getByTestId("Panel:Create copy job");
await expect(panel).toBeVisible();
await page.waitForTimeout(10 * 1000);
const subscriptionDropdown = panel.getByTestId("subscription-dropdown");
const expectedAccountName = targetAccountName;
expectedSubscriptionName = await subscriptionDropdown.locator("span.ms-Dropdown-title").innerText();
await subscriptionDropdown.click();
const subscriptionItem = await getDropdownItemByNameOrPosition(
frame,
{ name: expectedSubscriptionName },
{ ariaLabel: "Subscription" },
);
await subscriptionItem.click();
// Load account dropdown based on selected subscription
const accountDropdown = panel.getByTestId("account-dropdown");
await expect(accountDropdown).toHaveText(new RegExp(expectedAccountName));
await accountDropdown.click();
const accountItem = await getDropdownItemByNameOrPosition(
frame,
{ name: expectedAccountName },
{ ariaLabel: "Account" },
);
await accountItem.click();
// Verifying online or offline migration functionality
/**
* This test verifies the functionality of the migration type radio that toggles between
* online and offline container copy modes. It ensures that:
* 1. When online mode is selected, the user is directed to a permissions screen
* 2. When offline mode is selected, the user bypasses the permissions screen
* 3. The UI correctly reflects the selected migration type throughout the workflow
*/
const migrationTypeContainer = panel.getByTestId("migration-type");
const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i });
await onlineCopyRadioButton.click({ force: true });
await expect(migrationTypeContainer.getByTestId("migration-type-description-online")).toBeVisible();
await panel.getByRole("button", { name: "Next" }).click();
await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).toBeVisible();
await expect(panel.getByText("Online container copy", { exact: true })).toBeVisible();
await panel.getByRole("button", { name: "Previous" }).click();
const offlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Offline mode/i });
await offlineCopyRadioButton.click({ force: true });
await expect(migrationTypeContainer.getByTestId("migration-type-description-offline")).toBeVisible();
await panel.getByRole("button", { name: "Next" }).click();
await expect(panel.getByTestId("Panel:SelectSourceAndTargetContainers")).toBeVisible();
await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).not.toBeVisible();
// Verifying source and target container selection
const sourceContainerDropdown = panel.getByTestId("source-containerDropdown");
expect(sourceContainerDropdown).toBeVisible();
await expect(sourceContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/);
const sourceDatabaseDropdown = panel.getByTestId("source-databaseDropdown");
await sourceDatabaseDropdown.click();
const sourceDbDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Database" },
);
await sourceDbDropdownItem.click();
await expect(sourceContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
await sourceContainerDropdown.click();
const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Container" },
);
await sourceContainerDropdownItem.click();
const targetContainerDropdown = panel.getByTestId("target-containerDropdown");
expect(targetContainerDropdown).toBeVisible();
await expect(targetContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/);
const targetDatabaseDropdown = panel.getByTestId("target-databaseDropdown");
await targetDatabaseDropdown.click();
const targetDbDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Database" },
);
await targetDbDropdownItem.click();
await expect(targetContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
await targetContainerDropdown.click();
const targetContainerDropdownItem1 = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Container" },
);
await targetContainerDropdownItem1.click();
await panel.getByRole("button", { name: "Next" }).click();
const errorContainer = panel.getByTestId("Panel:ErrorContainer");
await expect(errorContainer).toBeVisible();
await expect(errorContainer).toHaveText(/Source and destination containers cannot be the same/i);
// Reselect target container to be different from source container
await targetContainerDropdown.click();
const targetContainerDropdownItem2 = await getDropdownItemByNameOrPosition(
frame,
{ position: 1 },
{ ariaLabel: "Container" },
);
await targetContainerDropdownItem2.click();
const selectedSourceDatabase = await sourceDatabaseDropdown.innerText();
const selectedSourceContainer = await sourceContainerDropdown.innerText();
const selectedTargetDatabase = await targetDatabaseDropdown.innerText();
const selectedTargetContainer = await targetContainerDropdown.innerText();
expectedCopyJobNameInitial = `${truncateName(selectedSourceDatabase)}.${truncateName(
selectedSourceContainer,
)}_${truncateName(selectedTargetDatabase)}.${truncateName(selectedTargetContainer)}`;
await panel.getByRole("button", { name: "Next" }).click();
await expect(errorContainer).not.toBeVisible();
await expect(panel.getByTestId("Panel:PreviewCopyJob")).toBeVisible();
// Verifying the preview of the copy job
const previewContainer = panel.getByTestId("Panel:PreviewCopyJob");
await expect(previewContainer).toBeVisible();
await expect(previewContainer.getByTestId("source-subscription-name")).toHaveText(expectedSubscriptionName);
await expect(previewContainer.getByTestId("source-account-name")).toHaveText(expectedAccountName);
const jobNameInput = previewContainer.getByTestId("job-name-textfield");
await expect(jobNameInput).toHaveValue(new RegExp(expectedCopyJobNameInitial));
const primaryBtn = panel.getByRole("button", { name: "Copy", exact: true });
await expect(primaryBtn).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
await jobNameInput.fill("test job name");
await expect(primaryBtn).toHaveClass(/(^|\s)is-disabled(\s|$)/);
// Testing API request interception with duplicate job name
const duplicateJobName = "test-job-name-1";
await jobNameInput.fill(duplicateJobName);
const copyButton = panel.getByRole("button", { name: "Copy", exact: true });
const expectedErrorMessage = `Duplicate job name '${duplicateJobName}'`;
await interceptAndInspectApiRequest(
page,
`${expectedAccountName}/dataTransferJobs/${duplicateJobName}`,
"PUT",
new Error(expectedErrorMessage),
(url?: string) => url?.includes(duplicateJobName) ?? false,
);
let errorThrown = false;
try {
await copyButton.click();
await page.waitForTimeout(2000);
} catch (error: any) {
errorThrown = true;
expect(error.message).toContain("not allowed");
}
if (!errorThrown) {
const errorContainer = panel.getByTestId("Panel:ErrorContainer");
await expect(errorContainer).toBeVisible();
await expect(errorContainer).toHaveText(new RegExp(expectedErrorMessage, "i"));
}
await expect(panel).toBeVisible();
// Testing API request success with valid job name and verifying copy job creation
const validJobName = expectedJobName;
const copyJobCreationPromise = waitForApiResponse(
page,
`${expectedAccountName}/dataTransferJobs/${validJobName}`,
"PUT",
);
await jobNameInput.fill(validJobName);
await expect(copyButton).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
await copyButton.click();
const response = await copyJobCreationPromise;
expect(response.ok()).toBe(true);
await expect(panel).not.toBeVisible({ timeout: 10000 });
const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page");
await jobsListContainer.waitFor({ state: "visible" });
const jobItem = jobsListContainer.getByText(validJobName);
await jobItem.waitFor({ state: "visible" });
await expect(jobItem).toBeVisible();
});
test("Verify Online or Offline Container Copy Permissions Panel", async () => {
expect(wrapper).not.toBeNull();
// Opening the Create Copy Job panel again to verify initial state
const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job");
await createCopyJobButton.click();
panel = frame.getByTestId("Panel:Create copy job");
await expect(panel).toBeVisible();
await expect(panel.getByRole("heading", { name: "Create copy job" })).toBeVisible();
// select different account dropdown
const accountDropdown = panel.getByTestId("account-dropdown");
await accountDropdown.click();
const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual("Account");
const allDropdownItems = await dropdownItemsWrapper.locator(`button.ms-Dropdown-item[role='option']`).all();
const filteredItems = [];
for (const item of allDropdownItems) {
const testContent = (await item.textContent()) ?? "";
if (testContent.trim() !== targetAccountName.trim()) {
filteredItems.push(item);
}
}
if (filteredItems.length > 0) {
const firstDropdownItem = filteredItems[0];
expectedSourceAccountName = (await firstDropdownItem.textContent()) ?? "";
await firstDropdownItem.click();
} else {
throw new Error("No dropdown items available after filtering");
}
const migrationTypeContainer = panel.getByTestId("migration-type");
const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i });
await onlineCopyRadioButton.click({ force: true });
await panel.getByRole("button", { name: "Next" }).click();
// Verifying Assign Permissions panel for online copy
const permissionScreen = panel.getByTestId("Panel:AssignPermissionsContainer");
await expect(permissionScreen).toBeVisible();
await expect(permissionScreen.getByText("Online container copy", { exact: true })).toBeVisible();
await expect(permissionScreen.getByText("Cross-account container copy", { exact: true })).toBeVisible();
// Verify Point-in-Time Restore timer and refresh button workflow
await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}**`, async (route) => {
const mockData = {
identity: {
type: "SystemAssigned",
principalId: "00-11-22-33",
},
properties: {
defaultIdentity: "SystemAssignedIdentity",
backupPolicy: {
type: "Continuous",
},
capabilities: [{ name: "EnableOnlineContainerCopy" }],
},
};
if (route.request().method() === "GET") {
const response = await route.fetch();
const actualData = await response.json();
const mergedData = { ...actualData };
set(mergedData, "identity", mockData.identity);
set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity);
set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy);
set(mergedData, "properties.capabilities", mockData.properties.capabilities);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mergedData),
});
} else {
await route.continue();
}
});
await expect(permissionScreen).toBeVisible();
const expandedOnlineAccordionHeader = permissionScreen
.getByTestId("permission-group-container-onlineConfigs")
.locator("button[aria-expanded='true']");
await expect(expandedOnlineAccordionHeader).toBeVisible();
const accordionItem = expandedOnlineAccordionHeader
.locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]")
.first();
const accordionPanel = accordionItem
.locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']")
.first();
await page.clock.install({ time: new Date("2024-01-01T10:00:00Z") });
const pitrBtn = accordionPanel.getByTestId("pointInTimeRestore:PrimaryBtn");
await expect(pitrBtn).toBeVisible();
await pitrBtn.click();
page.context().on("page", async (newPage) => {
const expectedUrlEndPattern = new RegExp(
`/providers/Microsoft.(DocumentDB|DocumentDb)/databaseAccounts/${expectedSourceAccountName}/backupRestore`,
);
expect(newPage.url()).toMatch(expectedUrlEndPattern);
await newPage.close();
});
const loadingOverlay = frame.locator("[data-test='loading-overlay']");
await expect(loadingOverlay).toBeVisible();
const refreshBtn = accordionPanel.getByTestId("pointInTimeRestore:RefreshBtn");
await expect(refreshBtn).not.toBeVisible();
// Fast forward time by 11 minutes (11 * 60 * 1000ms = 660000ms)
await page.clock.fastForward(11 * 60 * 1000);
await expect(refreshBtn).toBeVisible();
await expect(pitrBtn).not.toBeVisible();
// Veify Popover & Loading Overlay on permission screen with API mocks and accordion interactions
await page.route(
`**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/sqlRoleAssignments*`,
async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
value: [
{
principalId: "00-11-22-33",
roleDefinitionId: `Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/77-88-99`,
},
],
}),
});
},
);
await page.route("**/Microsoft.DocumentDB/databaseAccounts/*/77-88-99**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
value: [
{
name: "00000000-0000-0000-0000-000000000001",
},
],
}),
});
});
await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${targetAccountName}**`, async (route) => {
const mockData = {
identity: {
type: "SystemAssigned",
principalId: "00-11-22-33",
},
properties: {
defaultIdentity: "SystemAssignedIdentity",
backupPolicy: {
type: "Continuous",
},
capabilities: [{ name: "EnableOnlineContainerCopy" }],
},
};
if (route.request().method() === "PATCH") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ status: "Succeeded" }),
});
} else if (route.request().method() === "GET") {
// Get the actual response and merge with mock data
const response = await route.fetch();
const actualData = await response.json();
const mergedData = { ...actualData };
set(mergedData, "identity", mockData.identity);
set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity);
set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy);
set(mergedData, "properties.capabilities", mockData.properties.capabilities);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mergedData),
});
} else {
await route.continue();
}
});
await expect(permissionScreen).toBeVisible();
const expandedCrossAccordionHeader = permissionScreen
.getByTestId("permission-group-container-crossAccountConfigs")
.locator("button[aria-expanded='true']");
await expect(expandedCrossAccordionHeader).toBeVisible();
const crossAccordionItem = expandedCrossAccordionHeader
.locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]")
.first();
const crossAccordionPanel = crossAccordionItem
.locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']")
.first();
const toggleButton = crossAccordionPanel.getByTestId("btn-toggle");
await expect(toggleButton).toBeVisible();
await toggleButton.click();
const popover = frame.locator("[data-test='popover-container']");
await expect(popover).toBeVisible();
const yesButton = popover.getByRole("button", { name: /Yes/i });
const noButton = popover.getByRole("button", { name: /No/i });
await expect(yesButton).toBeVisible();
await expect(noButton).toBeVisible();
await yesButton.click();
await expect(loadingOverlay).toBeVisible();
await expect(loadingOverlay).toBeHidden({ timeout: 10 * 1000 });
await expect(popover).toBeHidden({ timeout: 10 * 1000 });
await panel.getByRole("button", { name: "Cancel" }).click();
});
test.afterAll("Container Copy - After All", async () => {
await page.unroute(/.*/, (route) => route.continue());
await page.close();
});
});

View File

@@ -1,264 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { expect, Frame, Locator, Page, test } from "@playwright/test";
import { truncateName } from "../../../src/Explorer/ContainerCopy/CopyJobUtils";
import {
ContainerCopy,
getAccountName,
getDropdownItemByNameOrPosition,
interceptAndInspectApiRequest,
TestAccount,
waitForApiResponse,
} from "../../fx";
import { createMultipleTestContainers, TestContainerContext } from "../../testData";
test.describe("Container Copy - Offline Migration", () => {
let contexts: TestContainerContext[];
let page: Page;
let wrapper: Locator;
let panel: Locator;
let frame: Frame;
let expectedJobName: string;
let targetAccountName: string;
let expectedSubscriptionName: string;
let expectedCopyJobNameInitial: string;
test.beforeEach("Setup for offline migration test", async ({ browser }) => {
contexts = await createMultipleTestContainers({ accountType: TestAccount.SQLContainerCopyOnly, containerCount: 2 });
page = await browser.newPage();
({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly));
expectedJobName = `offline_test_job_${Date.now()}`;
targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
});
test.afterEach("Cleanup after offline migration test", async () => {
await page.unroute(/.*/, (route) => route.continue());
await page.close();
await Promise.all(contexts.map((context) => context?.dispose()));
});
test("Successfully create and manage offline migration copy job", async () => {
expect(wrapper).not.toBeNull();
await wrapper.locator(".commandBarContainer").waitFor({ state: "visible" });
// Open Create Copy Job panel
const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job");
await expect(createCopyJobButton).toBeVisible();
await createCopyJobButton.click();
panel = frame.getByTestId("Panel:Create copy job");
await expect(panel).toBeVisible();
// Reduced wait time for better performance
await page.waitForTimeout(2000);
// Setup subscription and account
const subscriptionDropdown = panel.getByTestId("subscription-dropdown");
const expectedAccountName = targetAccountName;
expectedSubscriptionName = await subscriptionDropdown.locator("span.ms-Dropdown-title").innerText();
await subscriptionDropdown.click();
const subscriptionItem = await getDropdownItemByNameOrPosition(
frame,
{ name: expectedSubscriptionName },
{ ariaLabel: "Subscription" },
);
await subscriptionItem.click();
// Select account
const accountDropdown = panel.getByTestId("account-dropdown");
await expect(accountDropdown).toHaveText(new RegExp(expectedAccountName));
await accountDropdown.click();
const accountItem = await getDropdownItemByNameOrPosition(
frame,
{ name: expectedAccountName },
{ ariaLabel: "Account" },
);
await accountItem.click();
// Test offline migration mode toggle functionality
const migrationTypeContainer = panel.getByTestId("migration-type");
// First test online mode (should show permissions screen)
const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i });
await onlineCopyRadioButton.click({ force: true });
await expect(migrationTypeContainer.getByTestId("migration-type-description-online")).toBeVisible();
await panel.getByRole("button", { name: "Next" }).click();
await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).toBeVisible();
await expect(panel.getByText("Online container copy", { exact: true })).toBeVisible();
// Go back and switch to offline mode
await panel.getByRole("button", { name: "Previous" }).click();
const offlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Offline mode/i });
await offlineCopyRadioButton.click({ force: true });
await expect(migrationTypeContainer.getByTestId("migration-type-description-offline")).toBeVisible();
await panel.getByRole("button", { name: "Next" }).click();
// Verify we skip permissions screen in offline mode
await expect(panel.getByTestId("Panel:SelectSourceAndTargetContainers")).toBeVisible();
await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).not.toBeVisible();
// Test source and target container selection with validation
const sourceContainerDropdown = panel.getByTestId("source-containerDropdown");
expect(sourceContainerDropdown).toBeVisible();
await expect(sourceContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/);
// Select source database first (containers are disabled until database is selected)
const sourceDatabaseDropdown = panel.getByTestId("source-databaseDropdown");
await sourceDatabaseDropdown.click();
const sourceDbDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Database" },
);
await sourceDbDropdownItem.click();
// Now container dropdown should be enabled
await expect(sourceContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
await sourceContainerDropdown.click();
const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Container" },
);
await sourceContainerDropdownItem.click();
// Test target container selection
const targetContainerDropdown = panel.getByTestId("target-containerDropdown");
expect(targetContainerDropdown).toBeVisible();
await expect(targetContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/);
const targetDatabaseDropdown = panel.getByTestId("target-databaseDropdown");
await targetDatabaseDropdown.click();
const targetDbDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Database" },
);
await targetDbDropdownItem.click();
await expect(targetContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
await targetContainerDropdown.click();
// First try selecting the same container (should show error)
const targetContainerDropdownItem1 = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Container" },
);
await targetContainerDropdownItem1.click();
await panel.getByRole("button", { name: "Next" }).click();
// Verify validation error for same source and target containers
const errorContainer = panel.getByTestId("Panel:ErrorContainer");
await expect(errorContainer).toBeVisible();
await expect(errorContainer).toHaveText(/Source and destination containers cannot be the same/i);
// Select different target container
await targetContainerDropdown.click();
const targetContainerDropdownItem2 = await getDropdownItemByNameOrPosition(
frame,
{ position: 1 },
{ ariaLabel: "Container" },
);
await targetContainerDropdownItem2.click();
// Generate expected job name based on selections
const selectedSourceDatabase = await sourceDatabaseDropdown.innerText();
const selectedSourceContainer = await sourceContainerDropdown.innerText();
const selectedTargetDatabase = await targetDatabaseDropdown.innerText();
const selectedTargetContainer = await targetContainerDropdown.innerText();
expectedCopyJobNameInitial = `${truncateName(selectedSourceDatabase)}.${truncateName(
selectedSourceContainer,
)}_${truncateName(selectedTargetDatabase)}.${truncateName(selectedTargetContainer)}`;
await panel.getByRole("button", { name: "Next" }).click();
// Error should disappear and preview should be visible
await expect(errorContainer).not.toBeVisible();
await expect(panel.getByTestId("Panel:PreviewCopyJob")).toBeVisible();
// Verify job preview details
const previewContainer = panel.getByTestId("Panel:PreviewCopyJob");
await expect(previewContainer).toBeVisible();
await expect(previewContainer.getByTestId("source-subscription-name")).toHaveText(expectedSubscriptionName);
await expect(previewContainer.getByTestId("source-account-name")).toHaveText(expectedAccountName);
const jobNameInput = previewContainer.getByTestId("job-name-textfield");
await expect(jobNameInput).toHaveValue(new RegExp(expectedCopyJobNameInitial));
const primaryBtn = panel.getByRole("button", { name: "Copy", exact: true });
await expect(primaryBtn).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
// Test invalid job name validation (spaces not allowed)
await jobNameInput.fill("test job name");
await expect(primaryBtn).toHaveClass(/(^|\s)is-disabled(\s|$)/);
// Test duplicate job name error handling
const duplicateJobName = "test-job-name-1";
await jobNameInput.fill(duplicateJobName);
const copyButton = panel.getByRole("button", { name: "Copy", exact: true });
const expectedErrorMessage = `Duplicate job name '${duplicateJobName}'`;
await interceptAndInspectApiRequest(
page,
`${expectedAccountName}/dataTransferJobs/${duplicateJobName}`,
"PUT",
new Error(expectedErrorMessage),
(url?: string) => url?.includes(duplicateJobName) ?? false,
);
let errorThrown = false;
try {
await copyButton.click();
await page.waitForTimeout(2000);
} catch (error: any) {
errorThrown = true;
expect(error.message).toContain("not allowed");
}
if (!errorThrown) {
const errorContainer = panel.getByTestId("Panel:ErrorContainer");
await expect(errorContainer).toBeVisible();
await expect(errorContainer).toHaveText(new RegExp(expectedErrorMessage, "i"));
}
await expect(panel).toBeVisible();
// Test successful job creation with valid job name
const validJobName = expectedJobName;
const copyJobCreationPromise = waitForApiResponse(
page,
`${expectedAccountName}/dataTransferJobs/${validJobName}`,
"PUT",
);
await jobNameInput.fill(validJobName);
await expect(copyButton).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
await copyButton.click();
const response = await copyJobCreationPromise;
expect(response.ok()).toBe(true);
// Verify panel closes and job appears in the list
await expect(panel).not.toBeVisible();
const filterTextField = wrapper.getByTestId("CopyJobsList/FilterTextField");
await filterTextField.waitFor({ state: "visible" });
await filterTextField.fill(validJobName);
const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page");
await jobsListContainer.waitFor({ state: "visible" });
const jobItem = jobsListContainer.getByText(validJobName);
await jobItem.waitFor({ state: "visible" });
await expect(jobItem).toBeVisible();
});
});

View File

@@ -1,191 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { expect, Frame, Locator, Page, test } from "@playwright/test";
import {
ContainerCopy,
getAccountName,
getDropdownItemByNameOrPosition,
TestAccount,
waitForApiResponse,
} from "../../fx";
import { createMultipleTestContainers, TestContainerContext } from "../../testData";
test.describe("Container Copy - Online Migration", () => {
let contexts: TestContainerContext[];
let page: Page;
let wrapper: Locator;
let panel: Locator;
let frame: Frame;
let targetAccountName: string;
test.beforeEach("Setup for online migration test", async ({ browser }) => {
contexts = await createMultipleTestContainers({ accountType: TestAccount.SQLContainerCopyOnly, containerCount: 2 });
page = await browser.newPage();
({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly));
targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
});
test.afterEach("Cleanup after online migration test", async () => {
await page.unroute(/.*/, (route) => route.continue());
await page.close();
await Promise.all(contexts.map((context) => context?.dispose()));
});
test("Successfully create and manage online migration copy job", async () => {
expect(wrapper).not.toBeNull();
await wrapper.locator(".commandBarContainer").waitFor({ state: "visible" });
// Open Create Copy Job panel
const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job");
await expect(createCopyJobButton).toBeVisible();
await createCopyJobButton.click();
panel = frame.getByTestId("Panel:Create copy job");
await expect(panel).toBeVisible();
// Reduced wait time for better performance
await page.waitForTimeout(1000);
// Enable online migration mode
const migrationTypeContainer = panel.getByTestId("migration-type");
const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i });
await onlineCopyRadioButton.click({ force: true });
await expect(migrationTypeContainer.getByTestId("migration-type-description-online")).toBeVisible();
await panel.getByRole("button", { name: "Next" }).click();
// Verify permissions screen is shown for online migration
const permissionScreen = panel.getByTestId("Panel:AssignPermissionsContainer");
await expect(permissionScreen).toBeVisible();
await expect(permissionScreen.getByText("Online container copy", { exact: true })).toBeVisible();
// Skip permissions setup and proceed to container selection
await panel.getByRole("button", { name: "Next" }).click();
// Configure source and target containers for online migration
const sourceDatabaseDropdown = panel.getByTestId("source-databaseDropdown");
await sourceDatabaseDropdown.click();
const sourceDbDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Database" },
);
await sourceDbDropdownItem.click();
const sourceContainerDropdown = panel.getByTestId("source-containerDropdown");
await sourceContainerDropdown.click();
const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Container" },
);
await sourceContainerDropdownItem.click();
const targetDatabaseDropdown = panel.getByTestId("target-databaseDropdown");
await targetDatabaseDropdown.click();
const targetDbDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Database" },
);
await targetDbDropdownItem.click();
const targetContainerDropdown = panel.getByTestId("target-containerDropdown");
await targetContainerDropdown.click();
const targetContainerDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 1 },
{ ariaLabel: "Container" },
);
await targetContainerDropdownItem.click();
await panel.getByRole("button", { name: "Next" }).click();
// Verify job preview and create the online migration job
const previewContainer = panel.getByTestId("Panel:PreviewCopyJob");
await expect(previewContainer.getByTestId("source-account-name")).toHaveText(targetAccountName);
const jobNameInput = previewContainer.getByTestId("job-name-textfield");
const onlineMigrationJobName = await jobNameInput.inputValue();
const copyButton = panel.getByRole("button", { name: "Copy", exact: true });
const copyJobCreationPromise = waitForApiResponse(
page,
`${targetAccountName}/dataTransferJobs/${onlineMigrationJobName}`,
"PUT",
);
await copyButton.click();
await page.waitForTimeout(1000); // Reduced wait time
const response = await copyJobCreationPromise;
expect(response.ok()).toBe(true);
// Verify panel closes and job appears in the list
await expect(panel).not.toBeVisible();
const filterTextField = wrapper.getByTestId("CopyJobsList/FilterTextField");
await filterTextField.waitFor({ state: "visible" });
await filterTextField.fill(onlineMigrationJobName);
const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page");
await jobsListContainer.waitFor({ state: "visible" });
let jobRow, statusCell, actionMenuButton;
jobRow = jobsListContainer.locator(".ms-DetailsRow", { hasText: onlineMigrationJobName });
statusCell = jobRow.locator("[data-automationid='DetailsRowCell'][data-automation-key='CopyJobStatus']");
await jobRow.waitFor({ state: "visible" });
// Verify job status changes to queued state
await expect(statusCell).toContainText(/running|queued|pending/i);
// Test job lifecycle management through action menu
actionMenuButton = wrapper.getByTestId(`CopyJobActionMenu/Button:${onlineMigrationJobName}`);
await actionMenuButton.click();
// Test pause functionality
const pauseAction = frame.locator(".ms-ContextualMenu-list button:has-text('Pause')");
await pauseAction.click();
const pauseResponse = await waitForApiResponse(
page,
`${targetAccountName}/dataTransferJobs/${onlineMigrationJobName}/pause`,
"POST",
);
expect(pauseResponse.ok()).toBe(true);
// Verify job status changes to paused
jobRow = jobsListContainer.locator(".ms-DetailsRow", { hasText: onlineMigrationJobName });
await jobRow.waitFor({ state: "visible", timeout: 5000 });
statusCell = jobRow.locator("[data-automationid='DetailsRowCell'][data-automation-key='CopyJobStatus']");
await expect(statusCell).toContainText(/paused/i, { timeout: 5000 });
await page.waitForTimeout(1000);
// Test cancel job functionality
actionMenuButton = wrapper.getByTestId(`CopyJobActionMenu/Button:${onlineMigrationJobName}`);
await actionMenuButton.click();
await frame.locator(".ms-ContextualMenu-list button:has-text('Cancel')").click();
// Verify cancellation confirmation dialog
await expect(frame.locator(".ms-Dialog-main")).toBeVisible({ timeout: 2000 });
await expect(frame.locator(".ms-Dialog-main")).toContainText(onlineMigrationJobName);
const cancelDialogButton = frame.locator(".ms-Dialog-main").getByTestId("DialogButton:Cancel");
await expect(cancelDialogButton).toBeVisible();
await cancelDialogButton.click();
await expect(frame.locator(".ms-Dialog-main")).not.toBeVisible();
actionMenuButton = wrapper.getByTestId(`CopyJobActionMenu/Button:${onlineMigrationJobName}`);
await actionMenuButton.click();
await frame.locator(".ms-ContextualMenu-list button:has-text('Cancel')").click();
const confirmDialogButton = frame.locator(".ms-Dialog-main").getByTestId("DialogButton:Confirm");
await expect(confirmDialogButton).toBeVisible();
await confirmDialogButton.click();
// Verify final job status is cancelled
jobRow = jobsListContainer.locator(".ms-DetailsRow", { hasText: onlineMigrationJobName });
statusCell = jobRow.locator("[data-automationid='DetailsRowCell'][data-automation-key='CopyJobStatus']");
await expect(statusCell).toContainText(/cancelled/i, { timeout: 5000 });
});
});

View File

@@ -1,270 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { expect, Frame, Locator, Page, test } from "@playwright/test";
import { set } from "lodash";
import { ContainerCopy, getAccountName, TestAccount } from "../../fx";
const VISIBLE_TIMEOUT_MS = 30 * 1000;
test.describe("Container Copy - Permission Screen Verification", () => {
let page: Page;
let wrapper: Locator;
let panel: Locator;
let frame: Frame;
let targetAccountName: string;
let expectedSourceAccountName: string;
test.beforeEach("Setup for each test", async ({ browser }) => {
page = await browser.newPage();
({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly));
targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
});
test.afterEach("Cleanup after each test", async () => {
await page.unroute(/.*/, (route) => route.continue());
await page.close();
});
test("Verify online container copy permissions panel functionality", async () => {
expect(wrapper).not.toBeNull();
// Verify all command bar buttons are visible
await wrapper.locator(".commandBarContainer").waitFor({ state: "visible", timeout: VISIBLE_TIMEOUT_MS });
const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job");
await expect(createCopyJobButton).toBeVisible();
await expect(wrapper.getByTestId("CommandBar/Button:Refresh")).toBeVisible();
await expect(wrapper.getByTestId("CommandBar/Button:Feedback")).toBeVisible();
// Open the Create Copy Job panel
await createCopyJobButton.click();
panel = frame.getByTestId("Panel:Create copy job");
await expect(panel).toBeVisible();
await expect(panel.getByRole("heading", { name: "Create copy job" })).toBeVisible();
// Select a different account for cross-account testing
const accountDropdown = panel.getByTestId("account-dropdown");
await accountDropdown.click();
const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual("Account");
const allDropdownItems = await dropdownItemsWrapper.locator(`button.ms-Dropdown-item[role='option']`).all();
const filteredItems = [];
for (const item of allDropdownItems) {
const testContent = (await item.textContent()) ?? "";
if (testContent.trim() !== targetAccountName.trim()) {
filteredItems.push(item);
}
}
if (filteredItems.length > 0) {
const firstDropdownItem = filteredItems[0];
expectedSourceAccountName = (await firstDropdownItem.textContent()) ?? "";
await firstDropdownItem.click();
} else {
throw new Error("No dropdown items available after filtering");
}
// Enable online migration mode
const migrationTypeContainer = panel.getByTestId("migration-type");
const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i });
await onlineCopyRadioButton.click({ force: true });
await expect(migrationTypeContainer.getByTestId("migration-type-description-online")).toBeVisible();
await panel.getByRole("button", { name: "Next" }).click();
// Verify Assign Permissions panel for online copy
const permissionScreen = panel.getByTestId("Panel:AssignPermissionsContainer");
await expect(permissionScreen).toBeVisible();
await expect(permissionScreen.getByText("Online container copy", { exact: true })).toBeVisible();
await expect(permissionScreen.getByText("Cross-account container copy", { exact: true })).toBeVisible();
// Setup API mocking for the source account
await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}**`, async (route) => {
const mockData = {
identity: {
type: "SystemAssigned",
principalId: "00-11-22-33",
},
properties: {
defaultIdentity: "SystemAssignedIdentity",
backupPolicy: {
type: "Continuous",
},
capabilities: [{ name: "EnableOnlineContainerCopy" }],
},
};
if (route.request().method() === "GET") {
const response = await route.fetch();
const actualData = await response.json();
const mergedData = { ...actualData };
set(mergedData, "identity", mockData.identity);
set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity);
set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy);
set(mergedData, "properties.capabilities", mockData.properties.capabilities);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mergedData),
});
} else {
await route.continue();
}
});
// Verify Point-in-Time Restore functionality
const expandedOnlineAccordionHeader = permissionScreen
.getByTestId("permission-group-container-onlineConfigs")
.locator("button[aria-expanded='true']");
await expect(expandedOnlineAccordionHeader).toBeVisible();
const accordionItem = expandedOnlineAccordionHeader
.locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]")
.first();
const accordionPanel = accordionItem
.locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']")
.first();
// Install clock mock and test PITR functionality
await page.clock.install({ time: new Date("2024-01-01T10:00:00Z") });
const pitrBtn = accordionPanel.getByTestId("pointInTimeRestore:PrimaryBtn");
await expect(pitrBtn).toBeVisible();
await pitrBtn.click({ force: true });
// Verify new page opens with correct URL pattern
page.context().on("page", async (newPage) => {
const expectedUrlEndPattern = new RegExp(
`/providers/Microsoft.(DocumentDB|DocumentDb)/databaseAccounts/${expectedSourceAccountName}/backupRestore`,
);
expect(newPage.url()).toMatch(expectedUrlEndPattern);
await newPage.close();
});
const loadingOverlay = frame.locator("[data-test='loading-overlay']");
await expect(loadingOverlay).toBeVisible();
const refreshBtn = accordionPanel.getByTestId("pointInTimeRestore:RefreshBtn");
await expect(refreshBtn).not.toBeVisible();
// Fast forward time by 11 minutes
await page.clock.fastForward(11 * 60 * 1000);
await expect(refreshBtn).toBeVisible({ timeout: 5000 });
await expect(pitrBtn).not.toBeVisible();
// Setup additional API mocks for role assignments and permissions
await page.route(
`**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/sqlRoleAssignments*`,
async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
value: [
{
principalId: "00-11-22-33",
roleDefinitionId: `Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/77-88-99`,
},
],
}),
});
},
);
await page.route("**/Microsoft.DocumentDB/databaseAccounts/*/77-88-99**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
value: [
{
name: "00000000-0000-0000-0000-000000000001",
},
],
}),
});
});
await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${targetAccountName}**`, async (route) => {
const mockData = {
identity: {
type: "SystemAssigned",
principalId: "00-11-22-33",
},
properties: {
defaultIdentity: "SystemAssignedIdentity",
backupPolicy: {
type: "Continuous",
},
capabilities: [{ name: "EnableOnlineContainerCopy" }],
},
};
if (route.request().method() === "PATCH") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ status: "Succeeded" }),
});
} else if (route.request().method() === "GET") {
const response = await route.fetch();
const actualData = await response.json();
const mergedData = { ...actualData };
set(mergedData, "identity", mockData.identity);
set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity);
set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy);
set(mergedData, "properties.capabilities", mockData.properties.capabilities);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mergedData),
});
} else {
await route.continue();
}
});
// Verify cross-account permissions functionality
const expandedCrossAccordionHeader = permissionScreen
.getByTestId("permission-group-container-crossAccountConfigs")
.locator("button[aria-expanded='true']");
await expect(expandedCrossAccordionHeader).toBeVisible();
const crossAccordionItem = expandedCrossAccordionHeader
.locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]")
.first();
const crossAccordionPanel = crossAccordionItem
.locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']")
.first();
const toggleButton = crossAccordionPanel.getByTestId("btn-toggle");
await expect(toggleButton).toBeVisible();
await toggleButton.click({ force: true });
// Verify popover functionality
const popover = frame.locator("[data-test='popover-container']");
await expect(popover).toBeVisible();
const yesButton = popover.getByRole("button", { name: /Yes/i });
const noButton = popover.getByRole("button", { name: /No/i });
await expect(yesButton).toBeVisible();
await expect(noButton).toBeVisible();
await yesButton.click({ force: true });
// Verify loading states
await expect(loadingOverlay).toBeVisible();
await expect(loadingOverlay).toBeHidden({ timeout: 10 * 1000 });
await expect(popover).toBeHidden({ timeout: 10 * 1000 });
// Cancel the panel to clean up
await panel.getByRole("button", { name: "Cancel" }).click({ force: true });
});
});

363
test/sql/copy-job-create.md Normal file
View File

@@ -0,0 +1,363 @@
# Copy Job Creation Test Plan
## Application Overview
This test plan covers comprehensive testing of the Copy Job Creation flow in the Azure CosmosDB Portal. The feature allows users to create container copy jobs for migrating data between Cosmos DB containers with support for both online and offline migration modes. Testing focuses on UI validation, form submission, error handling, and end-to-end workflow verification.
## Test Scenarios
### 1. Copy Job Creation - Happy Path
**Seed:** `test/sql/copyjob.seed.spec.ts`
#### 1.1. Create Offline Copy Job Successfully
**File:** `tests/copy-job-creation/create-offline-copy-job-success.spec.ts`
**Steps:**
1. Wait for the copy job screen to load
2. Click 'Create Copy Job' button with data-test='CommandBar/Button:Create Copy Job'
3. Verify side panel opens with data-test='Panel:Create copy job'
4. Verify panel header contains 'Create copy job' text
5. Wait for subscription dropdown with data-test='subscription-dropdown' to populate
6. Wait for account dropdown with data-test='account-dropdown' to populate
7. Verify 'Offline mode' radio button is enabled and selected by default
8. Verify offline mode description with data-test='migration-type-description-offline' is visible
9. Click 'Next' button to proceed to container selection
10. Verify SelectSourceAndTargetContainers panel with data-test='Panel:SelectSourceAndTargetContainers' is visible
11. Select first option from source database dropdown with data-test='source-databaseDropdown'
12. Select first option from source container dropdown with data-test='source-containerDropdown'
13. Select first option from target database dropdown with data-test='target-databaseDropdown'
14. Select first option from target container dropdown with data-test='target-containerDropdown'
15. Verify error message appears at top of panel for same source and target selection
16. Select second option from target container dropdown to resolve error
17. Click 'Next' button to proceed to preview
18. Verify preview page displays selected source subscription, account, database and container
19. Enter valid job name in job name input field
20. Click 'Submit' button to create copy job
21. Wait for API response indicating successful job creation
22. Verify job appears in jobs list with correct name
23. Verify side panel is closed after successful submission
**Expected Results:**
- Copy job screen loads successfully
- Create Copy Job button is visible and clickable
- Side panel opens with correct title
- Panel header displays 'Create copy job'
- Subscription dropdown populates with available subscriptions
- Account dropdown populates with available accounts
- Offline mode radio button is enabled and selected
- Offline mode description is visible and contains expected text
- Navigation to container selection screen is successful
- Source and target container selection panel is displayed
- Database and container dropdowns populate with available options
- Validation error is displayed for identical source and target containers
- Error is resolved when different target container is selected
- Navigation to preview screen is successful
- Preview displays accurate selection details
- Job name can be entered without restrictions
- Copy job is successfully created via API
- New job appears in the monitoring list
- Side panel closes indicating completed workflow
#### 1.2. Create Online Copy Job Successfully
**File:** `tests/copy-job-creation/create-online-copy-job-success.spec.ts`
**Steps:**
1. Wait for the copy job screen to load
2. Click 'Create Copy Job' button
3. Verify side panel opens
4. Select 'Online mode' radio button
5. Verify online mode description with data-test='migration-type-description-online' is visible
6. Click 'Next' button
7. Verify permissions assignment panel with data-test='Panel:AssignPermissionsContainer' is displayed
8. Complete permissions assignment process
9. Navigate to container selection screen
10. Select source database and container
11. Select different target database and container
12. Navigate to preview screen
13. Verify all selected details are accurate
14. Enter valid job name
15. Submit copy job creation
16. Verify successful job creation and list update
**Expected Results:**
- Online mode selection triggers permissions workflow
- Permissions assignment panel is displayed correctly
- Online copy job is created successfully
- Job appears in list with online mode indicator
- All workflow steps complete without errors
### 2. Copy Job Creation - Validation & Error Handling
**Seed:** `test/sql/copyjob.seed.spec.ts`
#### 2.1. Validate Required Field Errors
**File:** `tests/copy-job-creation/required-field-validation.spec.ts`
**Steps:**
1. Open Create Copy Job panel
2. Attempt to click 'Next' without selecting subscription
3. Verify validation error for subscription field
4. Select subscription but leave account unselected
5. Attempt to proceed and verify account validation error
6. Select account and proceed to container selection
7. Attempt to proceed without selecting source database
8. Verify source database validation error
9. Select source database but leave container unselected
10. Verify source container validation error
11. Complete source selection but leave target unselected
12. Verify target selection validation errors
13. Complete all selections but proceed to preview with empty job name
14. Verify job name validation error with data-test='Panel:ErrorContainer'
**Expected Results:**
- Subscription selection is enforced
- Account selection is required after subscription selection
- Source database selection is mandatory
- Source container selection is mandatory
- Target database and container selection is mandatory
- Job name is required and properly validated
- All validation messages are clear and actionable
#### 2.2. Validate Job Name Input Restrictions
**File:** `tests/copy-job-creation/job-name-validation.spec.ts`
**Steps:**
1. Navigate through copy job creation to preview screen
2. Test job name with special characters (!@#$%^&*())
3. Verify validation error for invalid characters
4. Test job name with spaces at beginning and end
5. Verify whitespace handling
6. Test extremely long job name (>100 characters)
7. Verify length validation
8. Test job name with only numbers
9. Test job name starting with hyphen or underscore
10. Test valid job name with alphanumeric, hyphens, and underscores
11. Verify successful submission with valid name
**Expected Results:**
- Special characters trigger appropriate validation error
- Whitespace is handled according to business rules
- Length restrictions are enforced
- Numeric-only names are handled correctly
- Valid naming patterns are accepted
- Job creation succeeds with properly formatted name
#### 2.3. Handle Same Source and Target Selection Error
**File:** `tests/copy-job-creation/same-source-target-validation.spec.ts`
**Steps:**
1. Complete subscription and account selection
2. Navigate to container selection screen
3. Select same database for both source and target
4. Select same container for both source and target
5. Attempt to proceed to next screen
6. Verify error message appears with data-test='Panel:ErrorContainer'
7. Verify error message content indicates source and target cannot be identical
8. Change target container to different option
9. Verify error message disappears
10. Verify 'Next' button becomes enabled
11. Successfully proceed to preview screen
**Expected Results:**
- Identical source and target selection triggers validation error
- Error message clearly explains the restriction
- Error is dynamically resolved when selection changes
- Navigation is blocked while validation error persists
- Navigation resumes once valid selections are made
#### 2.4. Handle Network and API Errors
**File:** `tests/copy-job-creation/api-error-handling.spec.ts`
**Steps:**
1. Complete entire copy job creation flow
2. Mock API failure for job creation request
3. Submit copy job with valid data
4. Verify error message appears in panel
5. Verify panel remains open for retry
6. Mock network timeout scenario
7. Verify appropriate timeout error handling
8. Mock authentication error response
9. Verify auth error is handled gracefully
10. Restore normal API behavior
11. Verify successful submission after resolving API issues
**Expected Results:**
- API errors are caught and displayed to user
- Error messages are informative and actionable
- Panel remains accessible for retry attempts
- Different error types are handled appropriately
- Recovery flow works after resolving issues
### 3. Copy Job Creation - Edge Cases & Boundary Testing
**Seed:** `test/sql/copyjob.seed.spec.ts`
#### 3.1. Handle Large Numbers of Containers
**File:** `tests/copy-job-creation/large-container-lists.spec.ts`
**Steps:**
1. Set up test account with 50+ databases and containers
2. Open copy job creation panel
3. Navigate to container selection
4. Test dropdown performance with large lists
5. Verify search/filter functionality in dropdowns
6. Verify scrolling behavior in dropdown menus
7. Select containers from end of large lists
8. Complete job creation with selections from large datasets
**Expected Results:**
- Dropdowns handle large datasets without performance issues
- Search/filter functionality works correctly
- Scrolling in dropdowns is smooth and responsive
- Selections from any position in large lists work correctly
- Job creation completes successfully with large dataset selections
#### 3.2. Test Unicode and International Characters
**File:** `tests/copy-job-creation/unicode-character-handling.spec.ts`
**Steps:**
1. Navigate to job name input in preview screen
2. Test job name with Chinese characters (测试工作)
3. Test job name with Arabic characters (اختبار)
4. Test job name with emoji characters (📁💾)
5. Test job name with accented characters (café résumé)
6. Test mixed unicode and ASCII characters
7. Verify display and storage of unicode job names
8. Submit jobs with various unicode names
9. Verify unicode names appear correctly in jobs list
**Expected Results:**
- Unicode characters are handled correctly in input fields
- Job names with international characters are accepted
- Unicode names display correctly throughout UI
- Jobs with unicode names are created successfully
- Unicode job names appear correctly in monitoring list
#### 3.3. Test Browser Compatibility Features
**File:** `tests/copy-job-creation/browser-compatibility.spec.ts`
**Steps:**
1. Test copy job creation with browser back/forward navigation
2. Verify panel state preservation during navigation
3. Test browser refresh during copy job creation
4. Verify appropriate handling of page refresh
5. Test browser zoom levels (50%, 150%, 200%)
6. Verify UI scaling and usability at different zoom levels
7. Test with browser developer tools open
8. Verify functionality with reduced viewport size
**Expected Results:**
- Browser navigation doesn't break the copy job flow
- Panel state is preserved appropriately
- Page refresh handling is graceful
- UI remains functional at various zoom levels
- Responsive design adapts to different viewport sizes
#### 3.4. Test Concurrent User Scenarios
**File:** `tests/copy-job-creation/concurrent-operations.spec.ts`
**Steps:**
1. Start copy job creation process
2. Simulate another user creating job with same name
3. Attempt to submit copy job with conflicting name
4. Verify appropriate conflict resolution
5. Test creating multiple jobs in rapid succession
6. Verify each job creation is handled independently
7. Test opening multiple copy job creation panels
8. Verify panel management and state isolation
**Expected Results:**
- Name conflicts are detected and handled appropriately
- Multiple simultaneous job creations are managed correctly
- Each copy job creation maintains independent state
- Concurrent operations don't interfere with each other
### 4. Copy Job Creation - UI/UX Validation
**Seed:** `test/sql/copyjob.seed.spec.ts`
#### 4.1. Verify Panel Navigation and Controls
**File:** `tests/copy-job-creation/panel-navigation.spec.ts`
**Steps:**
1. Open copy job creation panel
2. Verify 'Previous' button is disabled on first screen
3. Verify 'Next' button state changes based on validation
4. Navigate forward through all screens
5. Verify 'Previous' button enables navigation backward
6. Verify breadcrumb or progress indication
7. Test 'Cancel' button at each step
8. Verify cancel confirmation dialog if applicable
9. Test panel close button (X) functionality
10. Verify unsaved changes warning if applicable
**Expected Results:**
- Navigation buttons are enabled/disabled appropriately
- Forward navigation respects validation requirements
- Backward navigation preserves user data
- Progress indication is clear and accurate
- Cancel functionality works consistently
- Panel close behavior is user-friendly
- Unsaved changes are handled appropriately
#### 4.2. Verify Dropdown and Form Controls
**File:** `tests/copy-job-creation/form-controls-validation.spec.ts`
**Steps:**
1. Test all dropdown controls for proper data-test attributes
2. Verify dropdown placeholder text is informative
3. Test dropdown option selection and deselection
4. Verify dropdown filtering/search if available
5. Test keyboard navigation in dropdowns (Tab, Enter, Escape)
6. Verify dropdown accessibility attributes (aria-label, role)
7. Test radio button selection and mutual exclusivity
8. Verify radio button labels and descriptions
9. Test input field focus and blur behavior
10. Verify form validation timing (on blur vs on submit)
**Expected Results:**
- All dropdowns have correct data-test attributes for automation
- Placeholder text provides clear guidance
- Option selection works reliably
- Keyboard navigation follows accessibility standards
- ARIA attributes support screen readers
- Radio buttons enforce single selection
- Form validation provides immediate feedback
- Input fields behave predictably
#### 4.3. Verify Loading States and Feedback
**File:** `tests/copy-job-creation/loading-states.spec.ts`
**Steps:**
1. Open copy job creation panel
2. Verify loading indicators while fetching subscriptions
3. Verify loading states for account dropdown population
4. Test loading behavior for database and container lists
5. Verify loading indicator during job submission
6. Test behavior when API calls take longer than expected
7. Verify loading state accessibility (announcements)
8. Test loading state cancellation if supported
9. Verify error states replace loading states appropriately
10. Test loading state recovery after temporary failures
**Expected Results:**
- Loading indicators appear for all asynchronous operations
- Loading states are visually clear and accessible
- Long-running operations provide appropriate feedback
- Loading states can be cancelled when appropriate
- Error handling gracefully replaces loading states
- Recovery from temporary failures is smooth

View File

@@ -0,0 +1,275 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { expect, Frame, Locator, Page, test } from "@playwright/test";
import {
ContainerCopy,
getAccountName,
getDropdownItemByNameOrPosition,
TestAccount,
waitForApiResponse
} from "../fx";
import { createMultipleTestContainers } from "../testData";
let page: Page;
let wrapper: Locator = null!;
let panel: Locator = null!;
let frame: Frame = null!;
let targetAccountName: string = "";
let expectedJobName: string = "";
const VISIBLE_TIMEOUT_MS = 30 * 1000;
test.describe.configure({ mode: "serial" });
test.describe("Copy Job Creation - Happy Path (Scenario 1)", () => {
test.beforeAll("Copy Job Creation - Before All", async ({ browser }) => {
await createMultipleTestContainers({ accountType: TestAccount.SQLContainerCopyOnly, containerCount: 3 });
page = await browser.newPage();
({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly));
expectedJobName = `test_job_${Date.now()}`;
targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
});
test.afterAll("Copy Job Creation - After All", async () => {
await page.unroute(/.*/, (route) => route.continue());
await page.close();
});
test.afterEach("Copy Job Creation - After Each", async () => {
await page.unroute(/.*/, (route) => route.continue());
});
test("1.1. Create Offline Copy Job Successfully", async () => {
// Step 1: Wait for the copy job screen to load
await expect(wrapper.getByTestId("CommandBar/Button:Create Copy Job")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
// Step 2: Click 'Create Copy Job' button
await wrapper.getByTestId("CommandBar/Button:Create Copy Job").click();
// Step 3: Verify side panel opens
panel = frame.getByTestId("Panel:Create copy job");
await expect(panel).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
// Step 4: Verify panel header contains 'Create copy job' text
await expect(panel.getByText("Create copy job")).toBeVisible();
// Step 5: Wait for subscription dropdown to populate
const subscriptionDropdown = panel.getByTestId("subscription-dropdown");
await expect(subscriptionDropdown).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
await subscriptionDropdown.waitFor({ state: "visible" });
// Step 6: Wait for account dropdown to populate
const accountDropdown = panel.getByTestId("account-dropdown");
await expect(accountDropdown).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
await accountDropdown.waitFor({ state: "visible" });
// Step 7: Verify 'Offline mode' radio button is enabled and selected by default
const offlineModeRadio = panel.getByRole("radio", { name: /offline/i });
await expect(offlineModeRadio).toBeVisible();
await expect(offlineModeRadio).toBeEnabled();
await expect(offlineModeRadio).toBeChecked();
// Step 8: Verify offline mode description is visible
const offlineModeDescription = panel.getByTestId("migration-type-description-offline");
await expect(offlineModeDescription).toBeVisible();
// Step 9: Click 'Next' button to proceed to container selection
const nextButton = panel.getByRole("button", { name: /next/i });
await nextButton.click();
// Step 10: Verify SelectSourceAndTargetContainers panel is visible
const containerSelectionPanel = frame.getByTestId("Panel:SelectSourceAndTargetContainers");
await expect(containerSelectionPanel).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
// Step 11: Select first option from source database dropdown
const sourceDatabaseDropdown = containerSelectionPanel.getByTestId("source-databaseDropdown");
await expect(sourceDatabaseDropdown).toBeVisible();
await sourceDatabaseDropdown.click();
const sourceDatabaseDropdownItem = await getDropdownItemByNameOrPosition(frame, { position: 0 });
await sourceDatabaseDropdownItem.click();
// Step 12: Select first option from source container dropdown
const sourceContainerDropdown = containerSelectionPanel.getByTestId("source-containerDropdown");
await expect(sourceContainerDropdown).toBeVisible();
await sourceContainerDropdown.click();
const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition(frame, { position: 0 });
await sourceContainerDropdownItem.click();
// Step 13: Select first option from target database dropdown
const targetDatabaseDropdown = containerSelectionPanel.getByTestId("target-databaseDropdown");
await expect(targetDatabaseDropdown).toBeVisible();
await targetDatabaseDropdown.click();
const targetDatabaseDropdownItem = await getDropdownItemByNameOrPosition(frame, { position: 0 });
await targetDatabaseDropdownItem.click();
// Step 14: Select first option from target container dropdown
const targetContainerDropdown = containerSelectionPanel.getByTestId("target-containerDropdown");
await expect(targetContainerDropdown).toBeVisible();
await targetContainerDropdown.click();
const targetContainerDropdownItem = await getDropdownItemByNameOrPosition(frame, { position: 0 });
await targetContainerDropdownItem.click();
// Step 15: Verify error message appears for same source and target selection
const errorContainer = containerSelectionPanel.getByTestId("Panel:ErrorContainer");
await expect(errorContainer).toBeVisible({ timeout: 5000 });
await expect(errorContainer).toContainText(/source.*target.*identical|same/i);
// Step 16: Select second option from target container dropdown to resolve error
await targetContainerDropdown.click();
const targetContainerDropdownItemSecond = await getDropdownItemByNameOrPosition(frame, { position: 1 });
await targetContainerDropdownItemSecond.click();
// Verify error message disappears
await expect(errorContainer).not.toBeVisible({ timeout: 5000 });
// Step 17: Click 'Next' button to proceed to preview
const nextButtonContainer = containerSelectionPanel.getByRole("button", { name: /next/i });
await expect(nextButtonContainer).toBeEnabled();
await nextButtonContainer.click();
// Step 18: Verify preview page displays selected details
const previewPanel = frame.locator("[data-testid*='preview'], [data-testid*='Preview']").first();
await expect(previewPanel).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
// Verify preview displays source and target information
await expect(previewPanel.getByText(/source/i)).toBeVisible();
await expect(previewPanel.getByText(/target/i)).toBeVisible();
await expect(previewPanel.getByText(targetAccountName)).toBeVisible();
// Step 19: Enter valid job name
const jobNameInput = previewPanel.getByRole("textbox").or(previewPanel.getByTestId("job-name-input"));
await expect(jobNameInput).toBeVisible();
await jobNameInput.fill(expectedJobName);
// Step 20: Click 'Submit' button to create copy job
const submitButton = previewPanel.getByRole("button", { name: /submit/i });
await expect(submitButton).toBeEnabled();
// Set up API response interception
const apiResponsePromise = waitForApiResponse(
page,
`${targetAccountName}/dataTransferJobs/${expectedJobName}`,
"PUT"
);
await submitButton.click();
// Step 21: Wait for API response indicating successful job creation
await apiResponsePromise;
// Step 22: Verify job appears in jobs list
await expect(panel).not.toBeVisible({ timeout: 10000 }); // Panel should close
await expect(wrapper.getByText(expectedJobName)).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
// Step 23: Verify side panel is closed after successful submission
await expect(frame.getByTestId("Panel:Create copy job")).not.toBeVisible();
});
test("1.2. Create Online Copy Job Successfully", async () => {
// Generate unique job name for this test
const onlineJobName = `online_job_${Date.now()}`;
// Step 1: Wait for the copy job screen to load
await expect(wrapper.getByTestId("CommandBar/Button:Create Copy Job")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
// Step 2: Click 'Create Copy Job' button
await wrapper.getByTestId("CommandBar/Button:Create Copy Job").click();
// Step 3: Verify side panel opens
panel = frame.getByTestId("Panel:Create copy job");
await expect(panel).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
// Step 4: Select 'Online mode' radio button
const onlineModeRadio = panel.getByRole("radio", { name: /online/i });
await expect(onlineModeRadio).toBeVisible();
await expect(onlineModeRadio).toBeEnabled();
await onlineModeRadio.click();
await expect(onlineModeRadio).toBeChecked();
// Step 5: Verify online mode description is visible
const onlineModeDescription = panel.getByTestId("migration-type-description-online");
await expect(onlineModeDescription).toBeVisible();
await expect(onlineModeDescription).toContainText(/online/i);
// Step 6: Click 'Next' button
const nextButton = panel.getByRole("button", { name: /next/i });
await nextButton.click();
// Step 7: Verify permissions assignment panel is displayed
const permissionsPanel = frame.getByTestId("Panel:AssignPermissionsContainer");
await expect(permissionsPanel).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
// Step 8: Complete permissions assignment process
// This step may involve clicking through permission assignment UI
const permissionsNextButton = permissionsPanel.getByRole("button", { name: /next|continue|proceed/i });
if (await permissionsNextButton.isVisible()) {
await permissionsNextButton.click();
}
// Step 9: Navigate to container selection screen
const containerSelectionPanel = frame.getByTestId("Panel:SelectSourceAndTargetContainers");
await expect(containerSelectionPanel).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
// Step 10: Select source database and container
const sourceDatabaseDropdown = containerSelectionPanel.getByTestId("source-databaseDropdown");
await sourceDatabaseDropdown.click();
const sourceDatabaseDropdownItem = await getDropdownItemByNameOrPosition(frame, { position: 0 });
await sourceDatabaseDropdownItem.click();
const sourceContainerDropdown = containerSelectionPanel.getByTestId("source-containerDropdown");
await sourceContainerDropdown.click();
const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition(frame, { position: 0 });
await sourceContainerDropdownItem.click();
// Step 11: Select different target database and container
const targetDatabaseDropdown = containerSelectionPanel.getByTestId("target-databaseDropdown");
await targetDatabaseDropdown.click();
const targetDatabaseDropdownItem = await getDropdownItemByNameOrPosition(frame, { position: 0 });
await targetDatabaseDropdownItem.click();
const targetContainerDropdown = containerSelectionPanel.getByTestId("target-containerDropdown");
await targetContainerDropdown.click();
const targetContainerDropdownItem = await getDropdownItemByNameOrPosition(frame, { position: 1 });
await targetContainerDropdownItem.click(); // Select different container
// Step 12: Navigate to preview screen
const nextButtonContainer = containerSelectionPanel.getByRole("button", { name: /next/i });
await expect(nextButtonContainer).toBeEnabled();
await nextButtonContainer.click();
// Step 13: Verify all selected details are accurate
const previewPanel = frame.locator("[data-testid*='preview'], [data-testid*='Preview']").first();
await expect(previewPanel).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
// Verify online mode is indicated in preview
await expect(previewPanel.getByText(/online/i)).toBeVisible();
await expect(previewPanel.getByText(targetAccountName)).toBeVisible();
// Step 14: Enter valid job name
const jobNameInput = previewPanel.getByRole("textbox").or(previewPanel.getByTestId("job-name-input"));
await jobNameInput.fill(onlineJobName);
// Step 15: Submit copy job creation
const submitButton = previewPanel.getByRole("button", { name: /submit/i });
await expect(submitButton).toBeEnabled();
// Set up API response interception for online job creation
const apiResponsePromise = waitForApiResponse(
page,
`${targetAccountName}/dataTransferJobs/${onlineJobName}`,
"PUT"
);
await submitButton.click();
// Step 16: Verify successful job creation and list update
await apiResponsePromise;
// Verify panel closes and job appears in list
await expect(panel).not.toBeVisible({ timeout: 10000 });
await expect(wrapper.getByText(onlineJobName)).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
// Verify online mode indicator in job list (if applicable)
const jobListItem = wrapper.locator(`[data-testid*="job-item"], tr, .job-row`).filter({ hasText: onlineJobName });
await expect(jobListItem).toBeVisible();
// Verify side panel is closed
await expect(frame.getByTestId("Panel:Create copy job")).not.toBeVisible();
});
});

Some files were not shown because too many files have changed in this diff Show More