Compare commits

..

7 Commits

Author SHA1 Message Date
Sindhu Balasubramanian
de11ece337 Cleanup CORSByPass 2026-01-12 17:07:23 -08:00
Sindhu Balasubramanian
90c694d33c Fix error in tests 2026-01-12 14:13:29 -08:00
Sindhu Balasubramanian
865e9c906b Run npm format 2026-01-08 13:16:22 -08:00
Sindhu Balasubramanian
1c34425dd8 Merge branch 'master' of https://github.com/Azure/cosmos-explorer 2026-01-08 13:00:47 -08:00
Sindhu Balasubramanian
50a244e6f9 Add Mongo Pagination tests 2026-01-08 12:55:24 -08:00
Sindhu Balasubramanian
9dad75c2f9 Remove localhost 2025-12-30 23:21:56 -08:00
Sindhu Balasubramanian
876b531248 Add changes for Load more option to work 2025-12-30 22:54:42 -08:00
73 changed files with 567 additions and 3779 deletions

View File

@@ -1,20 +1 @@
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
DATA_EXPLORER_ENDPOINT=https://localhost:1234/hostedExplorer.html

View File

@@ -1,71 +0,0 @@
---
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

@@ -1,61 +0,0 @@
---
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

@@ -1,78 +0,0 @@
---
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

@@ -201,18 +201,18 @@ jobs:
GREMLIN_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-gremlin.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$GREMLIN_TESTACCOUNT_TOKEN"
echo GREMLIN_TESTACCOUNT_TOKEN=$GREMLIN_TESTACCOUNT_TOKEN >> $GITHUB_ENV
CASSANDRA_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-cassandra.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$CASSANDRA_TESTACCOUNT_TOKEN"
echo CASSANDRA_TESTACCOUNT_TOKEN=$CASSANDRA_TESTACCOUNT_TOKEN >> $GITHUB_ENV
MONGO_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$MONGO_TESTACCOUNT_TOKEN"
echo MONGO_TESTACCOUNT_TOKEN=$MONGO_TESTACCOUNT_TOKEN >> $GITHUB_ENV
MONGO32_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo32.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$MONGO32_TESTACCOUNT_TOKEN"
echo MONGO32_TESTACCOUNT_TOKEN=$MONGO32_TESTACCOUNT_TOKEN >> $GITHUB_ENV
MONGO_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo-readonly.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$MONGO_READONLY_TESTACCOUNT_TOKEN"
echo MONGO_READONLY_TESTACCOUNT_TOKEN=$MONGO_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV
# CASSANDRA_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-cassandra.documents.azure.com/.default" -o tsv --query accessToken)
# echo "::add-mask::$CASSANDRA_TESTACCOUNT_TOKEN"
# echo CASSANDRA_TESTACCOUNT_TOKEN=$CASSANDRA_TESTACCOUNT_TOKEN >> $GITHUB_ENV
# MONGO_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo.documents.azure.com/.default" -o tsv --query accessToken)
# echo "::add-mask::$MONGO_TESTACCOUNT_TOKEN"
# echo MONGO_TESTACCOUNT_TOKEN=$MONGO_TESTACCOUNT_TOKEN >> $GITHUB_ENV
# MONGO32_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo32.documents.azure.com/.default" -o tsv --query accessToken)
# echo "::add-mask::$MONGO32_TESTACCOUNT_TOKEN"
# echo MONGO32_TESTACCOUNT_TOKEN=$MONGO32_TESTACCOUNT_TOKEN >> $GITHUB_ENV
# MONGO_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo-readonly.documents.azure.com/.default" -o tsv --query accessToken)
# echo "::add-mask::$MONGO_READONLY_TESTACCOUNT_TOKEN"
# echo MONGO_READONLY_TESTACCOUNT_TOKEN=$MONGO_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV
- name: List test files for shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}}
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --list
- name: Run test shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}}

View File

@@ -1,34 +0,0 @@
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

1
.gitignore vendored
View File

@@ -21,4 +21,3 @@ GettingStarted-ignore*.ipynb
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/

23
.vscode/mcp.json vendored
View File

@@ -1,23 +0,0 @@
{
"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": []
}

27
package-lock.json generated
View File

@@ -125,7 +125,7 @@
"@babel/preset-env": "7.24.7",
"@babel/preset-react": "7.24.7",
"@babel/preset-typescript": "7.24.7",
"@playwright/test": "1.57.0",
"@playwright/test": "1.49.1",
"@testing-library/react": "11.2.3",
"@types/applicationinsights-js": "1.0.7",
"@types/codemirror": "0.0.56",
@@ -10321,13 +10321,12 @@
}
},
"node_modules/@playwright/test": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz",
"integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.57.0"
"playwright": "1.49.1"
},
"bin": {
"playwright": "cli.js"
@@ -30904,13 +30903,12 @@
}
},
"node_modules/playwright": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
"integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.57.0"
"playwright-core": "1.49.1"
},
"bin": {
"playwright": "cli.js"
@@ -30923,11 +30921,10 @@
}
},
"node_modules/playwright-core": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
"integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},

View File

@@ -111,8 +111,8 @@
"tinykeys": "2.1.0",
"underscore": "1.12.1",
"utility-types": "3.10.0",
"uuid": "9.0.0",
"web-vitals": "4.2.4",
"uuid": "9.0.0",
"zustand": "3.5.0"
},
"devDependencies": {
@@ -120,7 +120,7 @@
"@babel/preset-env": "7.24.7",
"@babel/preset-react": "7.24.7",
"@babel/preset-typescript": "7.24.7",
"@playwright/test": "1.57.0",
"@playwright/test": "1.49.1",
"@testing-library/react": "11.2.3",
"@types/applicationinsights-js": "1.0.7",
"@types/codemirror": "0.0.56",
@@ -213,11 +213,7 @@
"copyToConsumers": "node copyToConsumers",
"test": "rimraf coverage && jest",
"test:debug": "jest --runInBand",
"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:e2e": "jest -c ./jest.config.playwright.js --detectOpenHandles",
"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/",

View File

@@ -6,17 +6,15 @@ export default defineConfig({
testDir: "test",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 3 : 2,
retries: process.env.CI ? 3 : 0,
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

@@ -1,48 +0,0 @@
#!/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 };

View File

@@ -1,48 +0,0 @@
#!/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

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

View File

@@ -9,7 +9,7 @@ import {
SqlStoredProcedureCreateUpdateParameters,
SqlStoredProcedureResource,
} from "../../Utils/arm/generatedClients/cosmos/types";
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { client } from "../CosmosClient";
import { handleError } from "../ErrorHandlingUtils";
@@ -20,7 +20,6 @@ export async function createStoredProcedure(
): Promise<StoredProcedureDefinition & Resource> {
const clearMessage = logConsoleProgress(`Creating stored procedure ${storedProcedure.id}`);
try {
let resource: StoredProcedureDefinition & Resource;
if (
userContext.authType === AuthType.AAD &&
!userContext.features.enableSDKoperations &&
@@ -61,16 +60,14 @@ export async function createStoredProcedure(
storedProcedure.id,
createSprocParams,
);
resource = rpResponse && (rpResponse.properties?.resource as StoredProcedureDefinition & Resource);
} else {
const response = await client()
.database(databaseId)
.container(collectionId)
.scripts.storedProcedures.create(storedProcedure);
resource = response.resource;
return rpResponse && (rpResponse.properties?.resource as StoredProcedureDefinition & Resource);
}
logConsoleInfo(`Successfully created stored procedure ${storedProcedure.id}`);
return resource;
const response = await client()
.database(databaseId)
.container(collectionId)
.scripts.storedProcedures.create(storedProcedure);
return response?.resource;
} catch (error) {
handleError(error, "CreateStoredProcedure", `Error while creating stored procedure ${storedProcedure.id}`);
throw error;

View File

@@ -3,7 +3,7 @@ import { AuthType } from "../../AuthType";
import { userContext } from "../../UserContext";
import { createUpdateSqlTrigger, getSqlTrigger } from "../../Utils/arm/generatedClients/cosmos/sqlResources";
import { SqlTriggerCreateUpdateParameters, SqlTriggerResource } from "../../Utils/arm/generatedClients/cosmos/types";
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { client } from "../CosmosClient";
import { handleError } from "../ErrorHandlingUtils";
@@ -14,7 +14,6 @@ export async function createTrigger(
): Promise<TriggerDefinition | SqlTriggerResource> {
const clearMessage = logConsoleProgress(`Creating trigger ${trigger.id}`);
try {
let resource: SqlTriggerResource | TriggerDefinition;
if (
userContext.authType === AuthType.AAD &&
!userContext.features.enableSDKoperations &&
@@ -53,16 +52,14 @@ export async function createTrigger(
trigger.id,
createTriggerParams,
);
resource = rpResponse && rpResponse.properties?.resource;
} else {
const sdkResponse = await client()
.database(databaseId)
.container(collectionId)
.scripts.triggers.create(trigger as unknown as TriggerDefinition); // TODO: TypeScript does not like the SQL SDK trigger type
resource = sdkResponse.resource;
return rpResponse && rpResponse.properties?.resource;
}
logConsoleInfo(`Successfully created trigger ${trigger.id}`);
return resource;
const response = await client()
.database(databaseId)
.container(collectionId)
.scripts.triggers.create(trigger as unknown as TriggerDefinition); // TODO: TypeScript does not like the SQL SDK trigger type
return response.resource;
} catch (error) {
handleError(error, "CreateTrigger", `Error while creating trigger ${trigger.id}`);
throw error;

View File

@@ -9,7 +9,7 @@ import {
SqlUserDefinedFunctionCreateUpdateParameters,
SqlUserDefinedFunctionResource,
} from "../../Utils/arm/generatedClients/cosmos/types";
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { client } from "../CosmosClient";
import { handleError } from "../ErrorHandlingUtils";
@@ -20,7 +20,6 @@ export async function createUserDefinedFunction(
): Promise<UserDefinedFunctionDefinition & Resource> {
const clearMessage = logConsoleProgress(`Creating user defined function ${userDefinedFunction.id}`);
try {
let resource: UserDefinedFunctionDefinition & Resource;
if (
userContext.authType === AuthType.AAD &&
!userContext.features.enableSDKoperations &&
@@ -61,17 +60,14 @@ export async function createUserDefinedFunction(
userDefinedFunction.id,
createUDFParams,
);
resource = rpResponse && (rpResponse.properties?.resource as UserDefinedFunctionDefinition & Resource);
} else {
const response = await client()
.database(databaseId)
.container(collectionId)
.scripts.userDefinedFunctions.create(userDefinedFunction);
resource = response.resource;
return rpResponse && (rpResponse.properties?.resource as UserDefinedFunctionDefinition & Resource);
}
logConsoleInfo(`Successfully created user defined function ${userDefinedFunction.id}`);
return resource;
const response = await client()
.database(databaseId)
.container(collectionId)
.scripts.userDefinedFunctions.create(userDefinedFunction);
return response?.resource;
} catch (error) {
handleError(
error,

View File

@@ -28,7 +28,6 @@ export async function deleteStoredProcedure(
} else {
await client().database(databaseId).container(collectionId).scripts.storedProcedure(storedProcedureId).delete();
}
logConsoleProgress(`Successfully deleted stored procedure ${storedProcedureId}`);
} catch (error) {
handleError(error, "DeleteStoredProcedure", `Error while deleting stored procedure ${storedProcedureId}`);
throw error;

View File

@@ -24,7 +24,6 @@ export async function deleteTrigger(databaseId: string, collectionId: string, tr
} else {
await client().database(databaseId).container(collectionId).scripts.trigger(triggerId).delete();
}
logConsoleProgress(`Successfully deleted trigger ${triggerId}`);
} catch (error) {
handleError(error, "DeleteTrigger", `Error while deleting trigger ${triggerId}`);
throw error;

View File

@@ -24,7 +24,6 @@ export async function deleteUserDefinedFunction(databaseId: string, collectionId
} else {
await client().database(databaseId).container(collectionId).scripts.userDefinedFunction(id).delete();
}
logConsoleProgress(`Successfully deleted user defined function ${id}`);
} catch (error) {
handleError(error, "DeleteUserDefinedFunction", `Error while deleting user defined function ${id}`);
throw error;

View File

@@ -25,18 +25,7 @@ export default {
subscriptionDropdownPlaceholder: "Select a subscription",
sourceAccountDropdownLabel: "Account",
sourceAccountDropdownPlaceholder: "Select an account",
migrationTypeOptions: {
offline: {
title: "Offline mode",
description:
"Offline container copy jobs let you copy data from a source container to a destination Cosmos DB container for supported APIs. To ensure data integrity between the source and destination, we recommend stopping updates on the source container before creating the copy job. Learn more about [offline copy jobs](https://learn.microsoft.com/azure/cosmos-db/how-to-container-copy?tabs=offline-copy&pivots=api-nosql).",
},
online: {
title: "Online mode",
description:
"Online container copy jobs let you copy data from a source container to a destination Cosmos DB NoSQL API container using the [All Versions and Delete](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview) change feed. This allows updates to continue on the source while data is copied. A brief downtime is required at the end to safely switch over client applications to the destination container. Learn more about [online copy jobs](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started).",
},
},
migrationTypeCheckboxLabel: "Copy container in offline mode",
// Select Source and Target Containers Screen
selectSourceAndTargetContainersDescription:

View File

@@ -1,241 +0,0 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../../Context/CopyJobContext";
import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
import { MigrationType } from "./MigrationType";
jest.mock("../../../../Context/CopyJobContext", () => ({
useCopyJobContext: jest.fn(),
}));
describe("MigrationType", () => {
const mockSetCopyJobState = jest.fn();
const defaultContextValue = {
copyJobState: {
jobName: "",
migrationType: CopyJobMigrationType.Online,
source: {
subscription: null as any,
account: null as any,
databaseId: "",
containerId: "",
},
target: {
subscriptionId: "",
account: null as any,
databaseId: "",
containerId: "",
},
sourceReadAccessFromTarget: false,
},
setCopyJobState: mockSetCopyJobState,
flow: { currentScreen: "selectAccount" },
setFlow: jest.fn(),
contextError: "",
setContextError: jest.fn(),
explorer: {} as any,
resetCopyJobState: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
(useCopyJobContext as jest.Mock).mockReturnValue(defaultContextValue);
});
describe("Component Rendering", () => {
it("should render migration type component with radio buttons", () => {
const { container } = render(<MigrationType />);
expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument();
expect(screen.getByRole("radiogroup")).toBeInTheDocument();
const offlineRadio = screen.getByRole("radio", {
name: ContainerCopyMessages.migrationTypeOptions.offline.title,
});
const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
expect(offlineRadio).toBeInTheDocument();
expect(onlineRadio).toBeInTheDocument();
expect(container).toMatchSnapshot();
});
it("should render with online mode selected by default", () => {
render(<MigrationType />);
const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
const offlineRadio = screen.getByRole("radio", {
name: ContainerCopyMessages.migrationTypeOptions.offline.title,
});
expect(onlineRadio).toBeChecked();
expect(offlineRadio).not.toBeChecked();
});
it("should render with offline mode selected when state is offline", () => {
(useCopyJobContext as jest.Mock).mockReturnValue({
...defaultContextValue,
copyJobState: {
...defaultContextValue.copyJobState,
migrationType: CopyJobMigrationType.Offline,
},
});
render(<MigrationType />);
const offlineRadio = screen.getByRole("radio", {
name: ContainerCopyMessages.migrationTypeOptions.offline.title,
});
const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
expect(offlineRadio).toBeChecked();
expect(onlineRadio).not.toBeChecked();
});
});
describe("Descriptions and Learn More Links", () => {
it("should render online description and learn more link when online is selected", () => {
const { container } = render(<MigrationType />);
expect(container.querySelector("[data-test='migration-type-description-online']")).toBeInTheDocument();
const learnMoreLink = screen.getByRole("link", {
name: "online copy jobs",
});
expect(learnMoreLink).toBeInTheDocument();
expect(learnMoreLink).toHaveAttribute(
"href",
"https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started",
);
expect(learnMoreLink).toHaveAttribute("target", "_blank");
});
it("should render offline description and learn more link when offline is selected", () => {
(useCopyJobContext as jest.Mock).mockReturnValue({
...defaultContextValue,
copyJobState: {
...defaultContextValue.copyJobState,
migrationType: CopyJobMigrationType.Offline,
},
});
const { container } = render(<MigrationType />);
expect(container.querySelector("[data-test='migration-type-description-offline']")).toBeInTheDocument();
const learnMoreLink = screen.getByRole("link", {
name: "offline copy jobs",
});
expect(learnMoreLink).toBeInTheDocument();
expect(learnMoreLink).toHaveAttribute(
"href",
"https://learn.microsoft.com/azure/cosmos-db/how-to-container-copy?tabs=offline-copy&pivots=api-nosql",
);
});
});
describe("User Interactions", () => {
it("should call setCopyJobState when offline radio button is clicked", () => {
render(<MigrationType />);
const offlineRadio = screen.getByRole("radio", {
name: ContainerCopyMessages.migrationTypeOptions.offline.title,
});
fireEvent.click(offlineRadio);
expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
const updateFunction = mockSetCopyJobState.mock.calls[0][0];
const result = updateFunction(defaultContextValue.copyJobState);
expect(result).toEqual({
...defaultContextValue.copyJobState,
migrationType: CopyJobMigrationType.Offline,
});
});
it("should call setCopyJobState when online radio button is clicked", () => {
(useCopyJobContext as jest.Mock).mockReturnValue({
...defaultContextValue,
copyJobState: {
...defaultContextValue.copyJobState,
migrationType: CopyJobMigrationType.Offline,
},
});
render(<MigrationType />);
const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
fireEvent.click(onlineRadio);
expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
const updateFunction = mockSetCopyJobState.mock.calls[0][0];
const result = updateFunction({
...defaultContextValue.copyJobState,
migrationType: CopyJobMigrationType.Offline,
});
expect(result).toEqual({
...defaultContextValue.copyJobState,
migrationType: CopyJobMigrationType.Online,
});
});
});
describe("Accessibility", () => {
it("should have proper ARIA attributes", () => {
render(<MigrationType />);
const choiceGroup = screen.getByRole("radiogroup");
expect(choiceGroup).toBeInTheDocument();
expect(choiceGroup).toHaveAttribute("aria-labelledby", "migrationTypeChoiceGroup");
});
it("should have proper radio button labels", () => {
render(<MigrationType />);
expect(
screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.offline.title }),
).toBeInTheDocument();
expect(
screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title }),
).toBeInTheDocument();
});
});
describe("Edge Cases", () => {
it("should handle undefined migration type gracefully", () => {
(useCopyJobContext as jest.Mock).mockReturnValue({
...defaultContextValue,
copyJobState: {
...defaultContextValue.copyJobState,
migrationType: undefined,
},
});
const { container } = render(<MigrationType />);
expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument();
expect(
screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.offline.title }),
).toBeInTheDocument();
expect(
screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title }),
).toBeInTheDocument();
});
it("should handle null copyJobState gracefully", () => {
(useCopyJobContext as jest.Mock).mockReturnValue({
...defaultContextValue,
copyJobState: null,
});
const { container } = render(<MigrationType />);
expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument();
});
});
});

View File

@@ -1,77 +0,0 @@
/* eslint-disable react/prop-types */
/* eslint-disable react/display-name */
import { ChoiceGroup, IChoiceGroupOption, Stack, Text } from "@fluentui/react";
import MarkdownRender from "@nteract/markdown";
import { useCopyJobContext } from "Explorer/ContainerCopy/Context/CopyJobContext";
import React from "react";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
interface MigrationTypeProps {}
const options: IChoiceGroupOption[] = [
{
key: CopyJobMigrationType.Offline,
text: ContainerCopyMessages.migrationTypeOptions.offline.title,
styles: { root: { width: "33%" } },
},
{
key: CopyJobMigrationType.Online,
text: ContainerCopyMessages.migrationTypeOptions.online.title,
styles: { root: { width: "33%" } },
},
];
const choiceGroupStyles = {
flexContainer: { display: "flex" as const },
root: {
selectors: {
".ms-ChoiceField": {
color: "var(--colorNeutralForeground1)",
},
".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
color: "var(--colorNeutralForeground1)",
},
},
},
};
export const MigrationType: React.FC<MigrationTypeProps> = React.memo(() => {
const { copyJobState, setCopyJobState } = useCopyJobContext();
const handleChange = (_ev?: React.FormEvent, option?: IChoiceGroupOption) => {
if (option) {
setCopyJobState((prevState) => ({
...prevState,
migrationType: option.key as CopyJobMigrationType,
}));
}
};
const selectedKey = copyJobState?.migrationType ?? "";
const selectedKeyLowercase = selectedKey.toLowerCase() as keyof typeof ContainerCopyMessages.migrationTypeOptions;
const selectedKeyContent = ContainerCopyMessages.migrationTypeOptions[selectedKeyLowercase];
return (
<Stack data-test="migration-type" className="migrationTypeContainer">
<Stack.Item>
<ChoiceGroup
selectedKey={selectedKey}
options={options}
onChange={handleChange}
ariaLabelledBy="migrationTypeChoiceGroup"
styles={choiceGroupStyles}
/>
</Stack.Item>
{selectedKeyContent && (
<Stack.Item styles={{ root: { marginTop: 10 } }}>
<Text
variant="small"
className="migrationTypeDescription"
data-test={`migration-type-description-${selectedKeyLowercase}`}
>
<MarkdownRender source={selectedKeyContent.description} linkTarget="_blank" />
</Text>
</Stack.Item>
)}
</Stack>
);
});

View File

@@ -0,0 +1,72 @@
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import React from "react";
import { MigrationTypeCheckbox } from "./MigrationTypeCheckbox";
describe("MigrationTypeCheckbox", () => {
const mockOnChange = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.clearAllMocks();
});
describe("Component Rendering", () => {
it("should render with default props (unchecked state)", () => {
const { container } = render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
expect(container.firstChild).toMatchSnapshot();
});
it("should render in checked state", () => {
const { container } = render(<MigrationTypeCheckbox checked={true} onChange={mockOnChange} />);
expect(container.firstChild).toMatchSnapshot();
});
it("should display the correct label text", () => {
render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
const checkbox = screen.getByRole("checkbox");
expect(checkbox).toBeInTheDocument();
const label = screen.getByText("Copy container in offline mode");
expect(label).toBeInTheDocument();
});
it("should have correct accessibility attributes when checked", () => {
render(<MigrationTypeCheckbox checked={true} onChange={mockOnChange} />);
const checkbox = screen.getByRole("checkbox");
expect(checkbox).toBeChecked();
expect(checkbox).toHaveAttribute("checked");
});
});
describe("FluentUI Integration", () => {
it("should render FluentUI Checkbox component correctly", () => {
render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
const checkbox = screen.getByRole("checkbox");
expect(checkbox).toBeInTheDocument();
expect(checkbox).toHaveAttribute("type", "checkbox");
});
it("should render FluentUI Stack component correctly", () => {
render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
const stackContainer = document.querySelector(".migrationTypeRow");
expect(stackContainer).toBeInTheDocument();
});
it("should apply FluentUI Stack horizontal alignment correctly", () => {
const { container } = render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
const stackContainer = container.querySelector(".migrationTypeRow");
expect(stackContainer).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,33 @@
/* eslint-disable react/prop-types */
/* eslint-disable react/display-name */
import { Checkbox, ICheckboxStyles, Stack } from "@fluentui/react";
import React from "react";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
interface MigrationTypeCheckboxProps {
checked: boolean;
onChange: (_ev?: React.FormEvent, checked?: boolean) => void;
}
const checkboxStyles: ICheckboxStyles = {
text: { color: "var(--colorNeutralForeground1)" },
checkbox: { borderColor: "var(--colorNeutralStroke1)" },
root: {
selectors: {
":hover .ms-Checkbox-text": { color: "var(--colorNeutralForeground1)" },
},
},
};
export const MigrationTypeCheckbox: React.FC<MigrationTypeCheckboxProps> = React.memo(({ checked, onChange }) => {
return (
<Stack horizontal horizontalAlign="space-between" className="migrationTypeRow" data-test="migration-type-checkbox">
<Checkbox
label={ContainerCopyMessages.migrationTypeCheckboxLabel}
checked={checked}
onChange={onChange}
styles={checkboxStyles}
/>
</Stack>
);
});

View File

@@ -1,109 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MigrationType Component Rendering should render migration type component with radio buttons 1`] = `
<div>
<div
class="ms-Stack migrationTypeContainer css-109"
data-test="migration-type"
>
<div
class="ms-StackItem css-110"
>
<div
class="ms-ChoiceFieldGroup root-111"
>
<div
aria-labelledby="migrationTypeChoiceGroup"
role="radiogroup"
>
<div
class="ms-ChoiceFieldGroup-flexContainer flexContainer-112"
>
<div
class="ms-ChoiceField root-113"
>
<div
class="ms-ChoiceField-wrapper"
>
<input
class="ms-ChoiceField-input input-114"
id="ChoiceGroup0-offline"
name="ChoiceGroup0"
type="radio"
/>
<label
class="ms-ChoiceField-field field-115"
for="ChoiceGroup0-offline"
>
<span
class="ms-ChoiceFieldLabel"
id="ChoiceGroupLabel1-offline"
>
Offline mode
</span>
</label>
</div>
</div>
<div
class="ms-ChoiceField root-113"
>
<div
class="ms-ChoiceField-wrapper"
>
<input
checked=""
class="ms-ChoiceField-input input-114"
id="ChoiceGroup0-online"
name="ChoiceGroup0"
type="radio"
/>
<label
class="ms-ChoiceField-field is-checked field-120"
for="ChoiceGroup0-online"
>
<span
class="ms-ChoiceFieldLabel"
id="ChoiceGroupLabel1-online"
>
Online mode
</span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="ms-StackItem css-123"
>
<span
class="migrationTypeDescription css-124"
data-test="migration-type-description-online"
>
<div
class="markdown-body "
>
<p>
Online container copy jobs let you copy data from a source container to a destination Cosmos DB NoSQL API container using the
<a
href="https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview"
target="_blank"
>
All Versions and Delete
</a>
change feed. This allows updates to continue on the source while data is copied. A brief downtime is required at the end to safely switch over client applications to the destination container. Learn more about
<a
href="https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started"
target="_blank"
>
online copy jobs
</a>
.
</p>
</div>
</span>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,82 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MigrationTypeCheckbox Component Rendering should render in checked state 1`] = `
<div
class="ms-Stack migrationTypeRow css-109"
data-test="migration-type-checkbox"
>
<div
class="ms-Checkbox is-checked is-enabled root-119"
>
<input
checked=""
class="input-111"
data-ktp-execute-target="true"
id="checkbox-1"
type="checkbox"
/>
<label
class="ms-Checkbox-label label-112"
for="checkbox-1"
>
<div
class="ms-Checkbox-checkbox checkbox-120"
data-ktp-target="true"
>
<i
aria-hidden="true"
class="ms-Checkbox-checkmark checkmark-122"
data-icon-name="CheckMark"
>
</i>
</div>
<span
class="ms-Checkbox-text text-115"
>
Copy container in offline mode
</span>
</label>
</div>
</div>
`;
exports[`MigrationTypeCheckbox Component Rendering should render with default props (unchecked state) 1`] = `
<div
class="ms-Stack migrationTypeRow css-109"
data-test="migration-type-checkbox"
>
<div
class="ms-Checkbox is-enabled root-110"
>
<input
class="input-111"
data-ktp-execute-target="true"
id="checkbox-0"
type="checkbox"
/>
<label
class="ms-Checkbox-label label-112"
for="checkbox-0"
>
<div
class="ms-Checkbox-checkbox checkbox-113"
data-ktp-target="true"
>
<i
aria-hidden="true"
class="ms-Checkbox-checkmark checkmark-118"
data-icon-name="CheckMark"
>
</i>
</div>
<span
class="ms-Checkbox-text text-115"
>
Copy container in offline mode
</span>
</label>
</div>
</div>
`;

View File

@@ -1,5 +1,5 @@
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
@@ -18,8 +18,19 @@ jest.mock("./Components/AccountDropdown", () => ({
AccountDropdown: jest.fn(() => <div data-testid="account-dropdown">Account Dropdown</div>),
}));
jest.mock("./Components/MigrationType", () => ({
MigrationType: jest.fn(() => <div data-testid="migration-type">Migration Type</div>),
jest.mock("./Components/MigrationTypeCheckbox", () => ({
MigrationTypeCheckbox: jest.fn(({ checked, onChange }: { checked: boolean; onChange: () => void }) => (
<div data-testid="migration-type-checkbox">
<input
type="checkbox"
checked={checked}
onChange={onChange}
data-testid="migration-checkbox-input"
aria-label="Migration Type Checkbox"
/>
Copy container in offline mode
</div>
)),
}));
describe("SelectAccount", () => {
@@ -72,7 +83,7 @@ describe("SelectAccount", () => {
expect(screen.getByTestId("subscription-dropdown")).toBeInTheDocument();
expect(screen.getByTestId("account-dropdown")).toBeInTheDocument();
expect(screen.getByTestId("migration-type")).toBeInTheDocument();
expect(screen.getByTestId("migration-type-checkbox")).toBeInTheDocument();
});
it("should render correctly with snapshot", () => {
@@ -82,20 +93,78 @@ describe("SelectAccount", () => {
});
describe("Migration Type Functionality", () => {
it("should render migration type component", () => {
it("should display migration type checkbox as unchecked when migrationType is Online", () => {
(useCopyJobContext as jest.Mock).mockReturnValue({
...defaultContextValue,
copyJobState: {
...defaultContextValue.copyJobState,
migrationType: CopyJobMigrationType.Online,
},
});
render(<SelectAccount />);
const migrationTypeComponent = screen.getByTestId("migration-type");
expect(migrationTypeComponent).toBeInTheDocument();
const checkbox = screen.getByTestId("migration-checkbox-input");
expect(checkbox).not.toBeChecked();
});
it("should display migration type checkbox as checked when migrationType is Offline", () => {
(useCopyJobContext as jest.Mock).mockReturnValue({
...defaultContextValue,
copyJobState: {
...defaultContextValue.copyJobState,
migrationType: CopyJobMigrationType.Offline,
},
});
render(<SelectAccount />);
const checkbox = screen.getByTestId("migration-checkbox-input");
expect(checkbox).toBeChecked();
});
it("should call setCopyJobState with Online migration type when checkbox is unchecked", () => {
(useCopyJobContext as jest.Mock).mockReturnValue({
...defaultContextValue,
copyJobState: {
...defaultContextValue.copyJobState,
migrationType: CopyJobMigrationType.Offline,
},
});
render(<SelectAccount />);
const checkbox = screen.getByTestId("migration-checkbox-input");
fireEvent.click(checkbox);
expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
const updateFunction = mockSetCopyJobState.mock.calls[0][0];
const previousState = {
...defaultContextValue.copyJobState,
migrationType: CopyJobMigrationType.Offline,
};
const result = updateFunction(previousState);
expect(result).toEqual({
...previousState,
migrationType: CopyJobMigrationType.Online,
});
});
});
describe("Performance and Optimization", () => {
it("should render without performance issues", () => {
it("should maintain referential equality of handler functions between renders", async () => {
const { rerender } = render(<SelectAccount />);
const migrationCheckbox = (await import("./Components/MigrationTypeCheckbox")).MigrationTypeCheckbox as jest.Mock;
const firstRenderHandler = migrationCheckbox.mock.calls[migrationCheckbox.mock.calls.length - 1][0].onChange;
rerender(<SelectAccount />);
expect(screen.getByTestId("migration-type")).toBeInTheDocument();
const secondRenderHandler = migrationCheckbox.mock.calls[migrationCheckbox.mock.calls.length - 1][0].onChange;
expect(firstRenderHandler).toBe(secondRenderHandler);
});
});
});

View File

@@ -1,11 +1,24 @@
import { Stack, Text } from "@fluentui/react";
import React from "react";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
import { AccountDropdown } from "./Components/AccountDropdown";
import { MigrationType } from "./Components/MigrationType";
import { MigrationTypeCheckbox } from "./Components/MigrationTypeCheckbox";
import { SubscriptionDropdown } from "./Components/SubscriptionDropdown";
const SelectAccount = React.memo(() => {
const { copyJobState, setCopyJobState } = useCopyJobContext();
const handleMigrationTypeChange = (_ev?: React.FormEvent<HTMLElement>, checked?: boolean) => {
setCopyJobState((prevState) => ({
...prevState,
migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online,
}));
};
const migrationTypeChecked = copyJobState?.migrationType === CopyJobMigrationType.Offline;
return (
<Stack data-test="Panel:SelectAccountContainer" className="selectAccountContainer" tokens={{ childrenGap: 15 }}>
<Text className="themeText">{ContainerCopyMessages.selectAccountDescription}</Text>
@@ -14,7 +27,7 @@ const SelectAccount = React.memo(() => {
<AccountDropdown />
<MigrationType />
<MigrationTypeCheckbox checked={migrationTypeChecked} onChange={handleMigrationTypeChange} />
</Stack>
);
});

View File

@@ -21,9 +21,14 @@ exports[`SelectAccount Component Rendering should render correctly with snapshot
Account Dropdown
</div>
<div
data-testid="migration-type"
data-testid="migration-type-checkbox"
>
Migration Type
<input
aria-label="Migration Type Checkbox"
data-testid="migration-checkbox-input"
type="checkbox"
/>
Copy container in offline mode
</div>
</div>
`;

View File

@@ -138,14 +138,6 @@
color: var(--colorNeutralForeground1);
}
}
.migrationTypeDescription {
p {
color: var(--colorNeutralForeground1);
}
a {
color: var(--colorBrandForeground1);
}
}
}
.create-container-link-btn {
padding: 0;
@@ -189,9 +181,6 @@
background-color: var(--colorNeutralBackground3);
}
}
.ms-DetailsHeader-cellTitle {
padding-left: 20px;
}
}
.ms-DetailsRow {

View File

@@ -1,8 +1,5 @@
import { IndexingPolicy } from "@azure/cosmos";
import { act } from "@testing-library/react";
import { AuthType } from "AuthType";
import { shallow } from "enzyme";
import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView";
import ko from "knockout";
import React from "react";
import { updateCollection } from "../../../Common/dataAccess/updateCollection";
@@ -447,49 +444,3 @@ describe("SettingsComponent", () => {
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(true);
});
});
describe("SettingsComponent - indexing policy subscription", () => {
const baseProps: SettingsComponentProps = {
settingsTab: new CollectionSettingsTabV2({
collection: collection,
tabKind: ViewModels.CollectionTabKind.CollectionSettingsV2,
title: "Scale & Settings",
tabPath: "",
node: undefined,
}),
};
it("subscribes to the correct container's indexing policy and updates state on change", async () => {
const containerId = collection.id();
const mockIndexingPolicy: IndexingPolicy = {
automatic: false,
indexingMode: "lazy",
includedPaths: [{ path: "/foo/*" }],
excludedPaths: [{ path: "/bar/*" }],
compositeIndexes: [],
spatialIndexes: [],
vectorIndexes: [],
fullTextIndexes: [],
};
const wrapper = shallow(<SettingsComponent {...baseProps} />);
const instance = wrapper.instance() as SettingsComponent;
await act(async () => {
useIndexingPolicyStore.setState({
indexingPolicies: {
[containerId]: mockIndexingPolicy,
},
});
// Wait for the async refreshCollectionData to complete
await new Promise((resolve) => setTimeout(resolve, 0));
});
wrapper.update();
expect(wrapper.state("indexingPolicyContent")).toEqual(mockIndexingPolicy);
expect(wrapper.state("indexingPolicyContentBaseline")).toEqual(mockIndexingPolicy);
// @ts-expect-error: rawDataModel is intentionally accessed for test validation
expect(instance.collection.rawDataModel.indexingPolicy).toEqual(mockIndexingPolicy);
});
});

View File

@@ -13,7 +13,6 @@ import {
ThroughputBucketsComponent,
ThroughputBucketsComponentProps,
} from "Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent";
import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView";
import { useDatabases } from "Explorer/useDatabases";
import { isFabricNative } from "Platform/Fabric/FabricUtil";
import { isCapabilityEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils";
@@ -74,6 +73,7 @@ import {
parseConflictResolutionMode,
parseConflictResolutionProcedure,
} from "./SettingsUtils";
interface SettingsV2TabInfo {
tab: SettingsV2TabTypes;
content: JSX.Element;
@@ -182,7 +182,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
private totalThroughputUsed: number;
private throughputBucketsEnabled: boolean;
public mongoDBCollectionResource: MongoDBCollectionResource;
private unsubscribe: () => void;
constructor(props: SettingsComponentProps) {
super(props);
@@ -312,13 +312,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
if (this.isCollectionSettingsTab) {
this.refreshIndexTransformationProgress();
this.loadMongoIndexes();
this.unsubscribe = useIndexingPolicyStore.subscribe(
() => {
this.refreshCollectionData();
},
(state) => state.indexingPolicies[this.collection?.id()],
);
this.refreshCollectionData();
}
this.setBaseline();
@@ -326,11 +319,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
useCommandBar.getState().setContextButtons(this.getTabsButtons());
}
}
componentWillUnmount(): void {
if (this.unsubscribe) {
this.unsubscribe();
}
}
componentDidUpdate(): void {
if (this.props.settingsTab.isActive()) {
useCommandBar.getState().setContextButtons(this.getTabsButtons());
@@ -860,6 +849,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
{ name: "name_of_property", query: "query_to_compute_property" },
] as DataModels.ComputedProperties;
}
const throughputBuckets = this.offer?.throughputBuckets;
return {
@@ -1019,31 +1009,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
startKey,
);
};
private refreshCollectionData = async (): Promise<void> => {
const containerId = this.collection.id();
const latestIndexingPolicy = useIndexingPolicyStore.getState().indexingPolicies[containerId];
const rawPolicy = latestIndexingPolicy ?? this.collection.indexingPolicy();
const latestCollection: DataModels.IndexingPolicy = {
automatic: rawPolicy?.automatic ?? true,
indexingMode: rawPolicy?.indexingMode ?? "consistent",
includedPaths: rawPolicy?.includedPaths ?? [],
excludedPaths: rawPolicy?.excludedPaths ?? [],
compositeIndexes: rawPolicy?.compositeIndexes ?? [],
spatialIndexes: rawPolicy?.spatialIndexes ?? [],
vectorIndexes: rawPolicy?.vectorIndexes ?? [],
fullTextIndexes: rawPolicy?.fullTextIndexes ?? [],
};
this.collection.rawDataModel.indexingPolicy = latestCollection;
this.setState({
indexingPolicyContent: latestCollection,
indexingPolicyContentBaseline: latestCollection,
});
};
private saveCollectionSettings = async (startKey: number): Promise<void> => {
const newCollection: DataModels.Collection = { ...this.collection.rawDataModel };
if (
this.state.isSubSettingsSaveable ||
this.state.isContainerPolicyDirty ||
@@ -1283,6 +1252,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
onScaleDiscardableChange: this.onScaleDiscardableChange,
throughputError: this.state.throughputError,
};
if (!this.isCollectionSettingsTab) {
return (
<div className="settingsV2MainContainer">

View File

@@ -155,12 +155,7 @@ export class ComputedPropertiesComponent extends React.Component<
</Link>
&#160; about how to define computed properties and how to use them.
</Text>
<div
className="settingsV2Editor"
tabIndex={0}
ref={this.computedPropertiesDiv}
data-test="computed-properties-editor"
></div>
<div className="settingsV2Editor" tabIndex={0} ref={this.computedPropertiesDiv}></div>
</Stack>
);
}

View File

@@ -1,10 +1,10 @@
import { MessageBar, MessageBarType } from "@fluentui/react";
import * as React from "react";
import { handleError } from "../../../../../Common/ErrorHandlingUtils";
import { MessageBar, MessageBarType } from "@fluentui/react";
import {
mongoIndexTransformationRefreshingMessage,
renderMongoIndexTransformationRefreshMessage,
} from "../../SettingsRenderUtils";
import { handleError } from "../../../../../Common/ErrorHandlingUtils";
import { isIndexTransforming } from "../../SettingsUtils";
export interface IndexingPolicyRefreshComponentProps {

View File

@@ -302,8 +302,8 @@ export class SubSettingsComponent extends React.Component<SubSettingsComponentPr
);
private geoSpatialConfigTypeChoiceGroupOptions: IChoiceGroupOption[] = [
{ key: GeospatialConfigType.Geography, text: "Geography", ariaLabel: "geography-option" },
{ key: GeospatialConfigType.Geometry, text: "Geometry", ariaLabel: "geometry-option" },
{ key: GeospatialConfigType.Geography, text: "Geography" },
{ key: GeospatialConfigType.Geometry, text: "Geometry" },
];
private getGeoSpatialComponent = (): JSX.Element => (

View File

@@ -31,7 +31,6 @@ exports[`ComputedPropertiesComponent renders 1`] = `
</Text>
<div
className="settingsV2Editor"
data-test="computed-properties-editor"
tabIndex={0}
/>
</Stack>

View File

@@ -167,12 +167,10 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},
@@ -654,12 +652,10 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},
@@ -1228,12 +1224,10 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},
@@ -1766,12 +1760,10 @@ exports[`SubSettingsComponent renders 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},
@@ -2338,12 +2330,10 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},

View File

@@ -153,16 +153,6 @@ exports[`SettingsComponent renders 1`] = `
"partitionKey",
],
"rawDataModel": {
"indexingPolicy": {
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
},
"uniqueKeyPolicy": {
"uniqueKeys": [
{
@@ -274,16 +264,6 @@ exports[`SettingsComponent renders 1`] = `
"partitionKey",
],
"rawDataModel": {
"indexingPolicy": {
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
},
"uniqueKeyPolicy": {
"uniqueKeys": [
{
@@ -496,16 +476,6 @@ exports[`SettingsComponent renders 1`] = `
"partitionKey",
],
"rawDataModel": {
"indexingPolicy": {
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
},
"uniqueKeyPolicy": {
"uniqueKeys": [
{
@@ -683,16 +653,6 @@ exports[`SettingsComponent renders 1`] = `
"partitionKey",
],
"rawDataModel": {
"indexingPolicy": {
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
},
"uniqueKeyPolicy": {
"uniqueKeys": [
{

View File

@@ -205,7 +205,7 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
tooltip="Select one or more JSON files to upload. Each file can contain a single JSON document or an array of JSON documents. The combined size of all files in an individual upload operation must be less than 2 MB. You can perform multiple upload operations for larger data sets."
/>
{uploadFileData?.length > 0 && (
<div className="fileUploadSummaryContainer" data-test="file-upload-status">
<div className="fileUploadSummaryContainer">
<b style={{ color: "var(--colorNeutralForeground1)" }}>File upload status</b>
<DetailsList
items={uploadFileData}

View File

@@ -1,107 +0,0 @@
import { CircleFilled } from "@fluentui/react-icons";
import type { IIndexMetric } from "Explorer/Tabs/QueryTab/ResultsView";
import { useIndexAdvisorStyles } from "Explorer/Tabs/QueryTab/StylesAdvisor";
import * as React from "react";
// SDK response format
export interface IndexMetricsResponse {
UtilizedIndexes?: {
SingleIndexes?: Array<{ IndexSpec: string; IndexImpactScore?: string }>;
CompositeIndexes?: Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>;
};
PotentialIndexes?: {
SingleIndexes?: Array<{ IndexSpec: string; IndexImpactScore?: string }>;
CompositeIndexes?: Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>;
};
}
export function parseIndexMetrics(indexMetrics: IndexMetricsResponse): {
included: IIndexMetric[];
notIncluded: IIndexMetric[];
} {
const included: IIndexMetric[] = [];
const notIncluded: IIndexMetric[] = [];
// Process UtilizedIndexes (Included in Current Policy)
if (indexMetrics.UtilizedIndexes) {
// Single indexes
indexMetrics.UtilizedIndexes.SingleIndexes?.forEach((index) => {
included.push({
index: index.IndexSpec,
impact: index.IndexImpactScore || "Utilized",
section: "Included",
path: index.IndexSpec,
});
});
// Composite indexes
indexMetrics.UtilizedIndexes.CompositeIndexes?.forEach((index) => {
const compositeSpec = index.IndexSpecs.join(", ");
included.push({
index: compositeSpec,
impact: index.IndexImpactScore || "Utilized",
section: "Included",
composite: index.IndexSpecs.map((spec) => {
const [path, order] = spec.trim().split(/\s+/);
return {
path: path.trim(),
order: order?.toLowerCase() === "desc" ? "descending" : "ascending",
};
}),
});
});
}
// Process PotentialIndexes (Not Included in Current Policy)
if (indexMetrics.PotentialIndexes) {
// Single indexes
indexMetrics.PotentialIndexes.SingleIndexes?.forEach((index) => {
notIncluded.push({
index: index.IndexSpec,
impact: index.IndexImpactScore || "Unknown",
section: "Not Included",
path: index.IndexSpec,
});
});
// Composite indexes
indexMetrics.PotentialIndexes.CompositeIndexes?.forEach((index) => {
const compositeSpec = index.IndexSpecs.join(", ");
notIncluded.push({
index: compositeSpec,
impact: index.IndexImpactScore || "Unknown",
section: "Not Included",
composite: index.IndexSpecs.map((spec) => {
const [path, order] = spec.trim().split(/\s+/);
return {
path: path.trim(),
order: order?.toLowerCase() === "desc" ? "descending" : "ascending",
};
}),
});
});
}
return { included, notIncluded };
}
export const renderImpactDots = (impact: string): JSX.Element => {
const style = useIndexAdvisorStyles();
let count = 0;
if (impact === "High") {
count = 3;
} else if (impact === "Medium") {
count = 2;
} else if (impact === "Low") {
count = 1;
}
return (
<div className={style.indexAdvisorImpactDots}>
{Array.from({ length: count }).map((_, i) => (
<CircleFilled key={i} className={style.indexAdvisorImpactDot} />
))}
</div>
);
};

View File

@@ -3,21 +3,18 @@ import QueryError from "Common/QueryError";
import { IndeterminateProgressBar } from "Explorer/Controls/IndeterminateProgressBar";
import { MessageBanner } from "Explorer/Controls/MessageBanner";
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
import useZoomLevel from "hooks/useZoomLevel";
import React from "react";
import { conditionalClass } from "Utils/StyleUtils";
import RunQuery from "../../../../images/RunQuery.png";
import { QueryResults } from "../../../Contracts/ViewModels";
import { ErrorList } from "./ErrorList";
import { ResultsView } from "./ResultsView";
import useZoomLevel from "hooks/useZoomLevel";
import { conditionalClass } from "Utils/StyleUtils";
export interface ResultsViewProps {
isMongoDB: boolean;
queryResults: QueryResults;
executeQueryDocumentsPage: (firstItemIndex: number) => Promise<void>;
queryEditorContent?: string;
databaseId?: string;
containerId?: string;
}
interface QueryResultProps extends ResultsViewProps {
@@ -52,8 +49,6 @@ export const QueryResultSection: React.FC<QueryResultProps> = ({
queryResults,
executeQueryDocumentsPage,
isExecuting,
databaseId,
containerId,
}: QueryResultProps): JSX.Element => {
const styles = useQueryTabStyles();
const maybeSubQuery = queryEditorContent && /.*\(.*SELECT.*\)/i.test(queryEditorContent);
@@ -96,9 +91,6 @@ export const QueryResultSection: React.FC<QueryResultProps> = ({
queryResults={queryResults}
executeQueryDocumentsPage={executeQueryDocumentsPage}
isMongoDB={isMongoDB}
queryEditorContent={queryEditorContent}
databaseId={databaseId}
containerId={containerId}
/>
) : (
<ExecuteQueryCallToAction />

View File

@@ -52,9 +52,8 @@ describe("QueryTabComponent", () => {
copilotVersion: "v3.0",
},
});
const propsMock: Readonly<IQueryTabComponentProps> = {
collection: { databaseId: "CopilotSampleDB", id: () => "CopilotContainer" },
collection: { databaseId: "CopilotSampleDB" },
onTabAccessor: () => jest.fn(),
isExecutionError: false,
tabId: "mockTabId",

View File

@@ -28,7 +28,6 @@ import { useMonacoTheme } from "hooks/useTheme";
import React, { Fragment, createRef } from "react";
import "react-splitter-layout/lib/index.css";
import { format } from "react-string-format";
import create from "zustand";
//TODO: Uncomment next two lines when query copilot is reinstated in DE
// import QueryCommandIcon from "../../../../images/CopilotCommand.svg";
// import LaunchCopilot from "../../../../images/CopilotTabIcon.svg";
@@ -58,20 +57,6 @@ import { SaveQueryPane } from "../../Panes/SaveQueryPane/SaveQueryPane";
import TabsBase from "../TabsBase";
import "./QueryTabComponent.less";
export interface QueryMetadataStore {
userQuery: string;
databaseId: string;
containerId: string;
setMetadata: (query1: string, db: string, container: string) => void;
}
export const useQueryMetadataStore = create<QueryMetadataStore>((set) => ({
userQuery: "",
databaseId: "",
containerId: "",
setMetadata: (query1, db, container) => set({ userQuery: query1, databaseId: db, containerId: container }),
}));
enum ToggleState {
Result,
QueryMetrics,
@@ -279,10 +264,6 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
}
public onExecuteQueryClick = async (): Promise<void> => {
const query1 = this.state.sqlQueryEditorContent;
const db = this.props.collection.databaseId;
const container = this.props.collection.id();
useQueryMetadataStore.getState().setMetadata(query1, db, container);
this._iterator = undefined;
setTimeout(async () => {
@@ -799,8 +780,6 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
errors={this.props.copilotStore?.errors}
isExecuting={this.props.copilotStore?.isExecuting}
queryResults={this.props.copilotStore?.queryResults}
databaseId={this.props.collection.databaseId}
containerId={this.props.collection.id()}
executeQueryDocumentsPage={(firstItemIndex: number) =>
QueryDocumentsPerPage(
firstItemIndex,
@@ -816,8 +795,6 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
errors={this.state.errors}
isExecuting={this.state.isExecuting}
queryResults={this.state.queryResults}
databaseId={this.props.collection.databaseId}
containerId={this.props.collection.id()}
executeQueryDocumentsPage={(firstItemIndex: number) =>
this._executeQueryDocumentsPage(firstItemIndex)
}

View File

@@ -1,170 +0,0 @@
import "@testing-library/jest-dom";
import { render, screen, waitFor } from "@testing-library/react";
import { IndexAdvisorTab } from "Explorer/Tabs/QueryTab/ResultsView";
import React from "react";
const mockReplace = jest.fn();
const mockFetchAll = jest.fn();
const mockRead = jest.fn();
const mockLogConsoleProgress = jest.fn();
const mockHandleError = jest.fn();
const indexMetricsResponse = {
UtilizedIndexes: {
SingleIndexes: [{ IndexSpec: "/foo/?", IndexImpactScore: "High" }],
CompositeIndexes: [{ IndexSpecs: ["/baz/? DESC", "/qux/? ASC"], IndexImpactScore: "Low" }],
},
PotentialIndexes: {
SingleIndexes: [{ IndexSpec: "/bar/?", IndexImpactScore: "Medium" }],
CompositeIndexes: [] as Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>,
},
};
const mockQueryResults = {
documents: [] as unknown[],
hasMoreResults: false,
itemCount: 0,
firstItemIndex: 0,
lastItemIndex: 0,
requestCharge: 0,
activityId: "test-activity-id",
};
mockRead.mockResolvedValue({
resource: {
indexingPolicy: {
automatic: true,
indexingMode: "consistent",
includedPaths: [{ path: "/*" }, { path: "/foo/?" }],
excludedPaths: [],
},
partitionKey: "pk",
},
});
mockReplace.mockResolvedValue({
resource: {
indexingPolicy: {
automatic: true,
indexingMode: "consistent",
includedPaths: [{ path: "/*" }],
excludedPaths: [],
},
},
});
jest.mock("Common/CosmosClient", () => ({
client: () => ({
database: () => ({
container: () => ({
items: {
query: () => ({
fetchAll: mockFetchAll,
}),
},
read: mockRead,
replace: mockReplace,
}),
}),
}),
}));
jest.mock("./StylesAdvisor", () => ({
useIndexAdvisorStyles: () => ({}),
}));
jest.mock("../../../Utils/NotificationConsoleUtils", () => ({
logConsoleProgress: (...args: unknown[]) => {
mockLogConsoleProgress(...args);
return () => {};
},
}));
jest.mock("../../../Common/ErrorHandlingUtils", () => ({
handleError: (...args: unknown[]) => mockHandleError(...args),
}));
beforeEach(() => {
jest.clearAllMocks();
mockFetchAll.mockResolvedValue({ indexMetrics: indexMetricsResponse });
});
describe("IndexAdvisorTab Basic Tests", () => {
test("component renders without crashing", () => {
const { container } = render(
<IndexAdvisorTab queryEditorContent="SELECT * FROM c" databaseId="db1" containerId="col1" />,
);
expect(container).toBeTruthy();
});
test("renders component and handles missing parameters", () => {
const { container } = render(<IndexAdvisorTab />);
expect(container).toBeTruthy();
// Should not crash when parameters are missing
});
test("fetches index metrics with query results", async () => {
render(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="db1"
containerId="col1"
/>,
);
await waitFor(() => expect(mockFetchAll).toHaveBeenCalled());
});
test("displays content after loading", async () => {
render(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="db1"
containerId="col1"
/>,
);
// Wait for the component to finish loading
await waitFor(() => expect(mockFetchAll).toHaveBeenCalled());
// Component should have rendered some content
expect(screen.getByText(/Index Advisor/i)).toBeInTheDocument();
});
test("calls log console progress when fetching metrics", async () => {
render(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="db1"
containerId="col1"
/>,
);
await waitFor(() => expect(mockLogConsoleProgress).toHaveBeenCalled());
});
test("handles error when fetch fails", async () => {
mockFetchAll.mockRejectedValueOnce(new Error("fetch failed"));
render(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="db1"
containerId="col1"
/>,
);
await waitFor(() => expect(mockHandleError).toHaveBeenCalled(), { timeout: 3000 });
});
test("renders with all required props", () => {
const { container } = render(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="testDb"
containerId="testContainer"
/>,
);
expect(container).toBeTruthy();
expect(container.firstChild).toBeTruthy();
});
});

View File

@@ -1,8 +1,5 @@
import type { CompositePath, IndexingPolicy } from "@azure/cosmos";
import { FontIcon } from "@fluentui/react";
import {
Button,
Checkbox,
DataGrid,
DataGridBody,
DataGridCell,
@@ -11,45 +8,28 @@ import {
DataGridRow,
SelectTabData,
SelectTabEvent,
Spinner,
Tab,
TabList,
Table,
TableBody,
TableCell,
TableColumnDefinition,
TableHeader,
TableRow,
createTableColumn,
} from "@fluentui/react-components";
import { ArrowDownloadRegular, ChevronDown20Regular, ChevronRight20Regular, CopyRegular } from "@fluentui/react-icons";
import copy from "clipboard-copy";
import { ArrowDownloadRegular, CopyRegular } from "@fluentui/react-icons";
import { HttpHeaders } from "Common/Constants";
import MongoUtility from "Common/MongoUtility";
import { QueryMetrics } from "Contracts/DataModels";
import { QueryResults } from "Contracts/ViewModels";
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
import {
parseIndexMetrics,
renderImpactDots,
type IndexMetricsResponse,
} from "Explorer/Tabs/QueryTab/IndexAdvisorUtils";
import { IDocument } from "Explorer/Tabs/QueryTab/QueryTabComponent";
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
import React, { useCallback, useEffect, useState } from "react";
import { userContext } from "UserContext";
import { logConsoleProgress } from "Utils/NotificationConsoleUtils";
import create from "zustand";
import { client } from "../../../Common/CosmosClient";
import { handleError } from "../../../Common/ErrorHandlingUtils";
import { sampleDataClient } from "../../../Common/SampleDataClient";
import copy from "clipboard-copy";
import React, { useCallback, useState } from "react";
import { ResultsViewProps } from "./QueryResultSection";
import { useIndexAdvisorStyles } from "./StylesAdvisor";
enum ResultsTabs {
Results = "results",
QueryStats = "queryStats",
IndexAdvisor = "indexadv",
}
const ResultsTab: React.FC<ResultsViewProps> = ({ queryResults, isMongoDB, executeQueryDocumentsPage }) => {
const styles = useQueryTabStyles();
/* eslint-disable react/prop-types */
@@ -543,331 +523,14 @@ const QueryStatsTab: React.FC<Pick<ResultsViewProps, "queryResults">> = ({ query
);
};
export interface IIndexMetric {
index: string;
impact: string;
section: "Included" | "Not Included" | "Header";
path?: string;
composite?: { path: string; order: string }[];
}
export const IndexAdvisorTab: React.FC<{
queryResults?: QueryResults;
queryEditorContent?: string;
databaseId?: string;
containerId?: string;
}> = ({ queryResults, queryEditorContent, databaseId, containerId }) => {
const style = useIndexAdvisorStyles();
const [loading, setLoading] = useState(false);
const [indexMetrics, setIndexMetrics] = useState<IndexMetricsResponse | null>(null);
const [showIncluded, setShowIncluded] = useState(true);
const [showNotIncluded, setShowNotIncluded] = useState(true);
const [selectedIndexes, setSelectedIndexes] = useState<IIndexMetric[]>([]);
const [selectAll, setSelectAll] = useState(false);
const [updateMessageShown, setUpdateMessageShown] = useState(false);
const [included, setIncludedIndexes] = useState<IIndexMetric[]>([]);
const [notIncluded, setNotIncludedIndexes] = useState<IIndexMetric[]>([]);
const [isUpdating, setIsUpdating] = useState(false);
const [justUpdatedPolicy, setJustUpdatedPolicy] = useState(false);
const indexingMetricsDocLink = "https://learn.microsoft.com/azure/cosmos-db/nosql/index-metrics";
const fetchIndexMetrics = async () => {
if (!queryEditorContent || !databaseId || !containerId) {
return;
}
setLoading(true);
const clearMessage = logConsoleProgress(`Querying items with IndexMetrics in container ${containerId}`);
try {
const querySpec = {
query: queryEditorContent,
};
// Use sampleDataClient for CopilotSampleDB, regular client for other databases
const cosmosClient = databaseId === "CopilotSampleDB" ? sampleDataClient() : client();
const sdkResponse = await cosmosClient
.database(databaseId)
.container(containerId)
.items.query(querySpec, {
populateIndexMetrics: true,
})
.fetchAll();
const parsedMetrics =
typeof sdkResponse.indexMetrics === "string" ? JSON.parse(sdkResponse.indexMetrics) : sdkResponse.indexMetrics;
setIndexMetrics(parsedMetrics);
} catch (error) {
handleError(error, "queryItemsWithIndexMetrics", `Error querying items from ${containerId}`);
} finally {
clearMessage();
setLoading(false);
}
};
// Fetch index metrics when query results change (i.e., when Execute Query is clicked)
useEffect(() => {
if (queryEditorContent && databaseId && containerId && queryResults) {
fetchIndexMetrics();
}
}, [queryResults]);
useEffect(() => {
if (!indexMetrics) {
return;
}
const { included, notIncluded } = parseIndexMetrics(indexMetrics);
setIncludedIndexes(included);
setNotIncludedIndexes(notIncluded);
if (justUpdatedPolicy) {
setJustUpdatedPolicy(false);
} else {
setUpdateMessageShown(false);
}
}, [indexMetrics]);
useEffect(() => {
const allSelected =
notIncluded.length > 0 && notIncluded.every((item) => selectedIndexes.some((s) => s.index === item.index));
setSelectAll(allSelected);
}, [selectedIndexes, notIncluded]);
const handleCheckboxChange = (indexObj: IIndexMetric, checked: boolean) => {
if (checked) {
setSelectedIndexes((prev) => [...prev, indexObj]);
} else {
setSelectedIndexes((prev) => prev.filter((item) => item.index !== indexObj.index));
}
};
const handleSelectAll = (checked: boolean) => {
setSelectAll(checked);
setSelectedIndexes(checked ? notIncluded : []);
};
const handleUpdatePolicy = async () => {
setIsUpdating(true);
try {
const containerRef = client().database(databaseId).container(containerId);
const { resource: containerDef } = await containerRef.read();
const newIncludedPaths = selectedIndexes
.filter((index) => !index.composite)
.map((index) => {
return {
path: index.path,
};
});
const newCompositeIndexes: CompositePath[][] = selectedIndexes
.filter((index) => Array.isArray(index.composite))
.map(
(index) =>
(index.composite as { path: string; order: string }[]).map((comp) => ({
path: comp.path,
order: comp.order === "descending" ? "descending" : "ascending",
})) as CompositePath[],
);
const updatedPolicy: IndexingPolicy = {
...containerDef.indexingPolicy,
includedPaths: [...(containerDef.indexingPolicy?.includedPaths || []), ...newIncludedPaths],
compositeIndexes: [...(containerDef.indexingPolicy?.compositeIndexes || []), ...newCompositeIndexes],
automatic: containerDef.indexingPolicy?.automatic ?? true,
indexingMode: containerDef.indexingPolicy?.indexingMode ?? "consistent",
excludedPaths: containerDef.indexingPolicy?.excludedPaths ?? [],
};
await containerRef.replace({
id: containerId,
partitionKey: containerDef.partitionKey,
indexingPolicy: updatedPolicy,
});
useIndexingPolicyStore.getState().setIndexingPolicyFor(containerId, updatedPolicy);
const selectedIndexSet = new Set(selectedIndexes.map((s) => s.index));
const updatedNotIncluded: typeof notIncluded = [];
const newlyIncluded: typeof included = [];
for (const item of notIncluded) {
if (selectedIndexSet.has(item.index)) {
newlyIncluded.push(item);
} else {
updatedNotIncluded.push(item);
}
}
const newIncluded = [...included, ...newlyIncluded];
const newNotIncluded = updatedNotIncluded;
setIncludedIndexes(newIncluded);
setNotIncludedIndexes(newNotIncluded);
setSelectedIndexes([]);
setSelectAll(false);
setUpdateMessageShown(true);
setJustUpdatedPolicy(true);
} catch (err) {
console.error("Failed to update indexing policy:", err);
} finally {
setIsUpdating(false);
}
};
const renderRow = (item: IIndexMetric, index: number) => {
const isHeader = item.section === "Header";
const isNotIncluded = item.section === "Not Included";
return (
<TableRow key={index}>
<TableCell colSpan={2}>
<div className={style.indexAdvisorGrid}>
{isNotIncluded ? (
<Checkbox
checked={selectedIndexes.some((selected) => selected.index === item.index)}
onChange={(_, data) => handleCheckboxChange(item, data.checked === true)}
/>
) : isHeader && item.index === "Not Included in Current Policy" && notIncluded.length > 0 ? (
<Checkbox checked={selectAll} onChange={(_, data) => handleSelectAll(data.checked === true)} />
) : (
<div className={style.indexAdvisorCheckboxSpacer}></div>
)}
{isHeader ? (
<span
style={{ cursor: "pointer" }}
onClick={() => {
if (item.index === "Included in Current Policy") {
setShowIncluded(!showIncluded);
} else if (item.index === "Not Included in Current Policy") {
setShowNotIncluded(!showNotIncluded);
}
}}
>
{item.index === "Included in Current Policy" ? (
showIncluded ? (
<ChevronDown20Regular />
) : (
<ChevronRight20Regular />
)
) : showNotIncluded ? (
<ChevronDown20Regular />
) : (
<ChevronRight20Regular />
)}
</span>
) : (
<div className={style.indexAdvisorChevronSpacer}></div>
)}
<div className={isHeader ? style.indexAdvisorRowBold : style.indexAdvisorRowNormal}>{item.index}</div>
<div className={isHeader ? style.indexAdvisorRowImpactHeader : style.indexAdvisorRowImpact}>
{!isHeader && item.impact}
</div>
<div>{!isHeader && renderImpactDots(item.impact)}</div>
</div>
</TableCell>
</TableRow>
);
};
const indexMetricItems = React.useMemo(() => {
const items: IIndexMetric[] = [];
items.push({ index: "Not Included in Current Policy", impact: "", section: "Header" });
if (showNotIncluded) {
notIncluded.forEach((item) => items.push({ ...item, section: "Not Included" }));
}
items.push({ index: "Included in Current Policy", impact: "", section: "Header" });
if (showIncluded) {
included.forEach((item) => items.push({ ...item, section: "Included" }));
}
return items;
}, [included, notIncluded, showIncluded, showNotIncluded]);
if (loading) {
return (
<div>
<Spinner
size="small"
style={
{
"--spinner-size": "16px",
"--spinner-thickness": "2px",
"--spinner-color": "#0078D4",
} as React.CSSProperties
}
/>
</div>
);
}
return (
<div>
<div className={style.indexAdvisorMessage}>
{updateMessageShown ? (
<>
<span className={style.indexAdvisorSuccessIcon}>
<FontIcon iconName="CheckMark" style={{ color: "white", fontSize: 12 }} />
</span>
<span>
Your indexing policy has been updated with the new included paths. You may review the changes in Scale &
Settings.
</span>
</>
) : (
<>
<span>
Index Advisor uses Indexing Metrics to suggest query paths that, when included in your indexing policy,
can improve the performance of this query by reducing RU costs and lowering latency.{" "}
<a href={indexingMetricsDocLink} target="_blank" rel="noopener noreferrer">
Learn more about Indexing Metrics
</a>
.{" "}
</span>
</>
)}
</div>
<div className={style.indexAdvisorTitle}>Indexes analysis</div>
<Table className={style.indexAdvisorTable}>
<TableHeader>
<TableRow>
<TableCell colSpan={2}>
<div className={style.indexAdvisorGrid}>
<div className={style.indexAdvisorCheckboxSpacer}></div>
<div className={style.indexAdvisorChevronSpacer}></div>
<div>Index</div>
<div>
<span style={{ whiteSpace: "nowrap" }}>Estimated Impact</span>
</div>
</div>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>{indexMetricItems.map(renderRow)}</TableBody>
</Table>
{selectedIndexes.length > 0 && (
<div className={style.indexAdvisorButtonBar}>
{isUpdating ? (
<div className={style.indexAdvisorButtonSpinner}>
<Spinner size="tiny" />{" "}
</div>
) : (
<button onClick={handleUpdatePolicy} className={style.indexAdvisorButton}>
Update Indexing Policy with selected index(es)
</button>
)}
</div>
)}
</div>
);
};
export const ResultsView: React.FC<ResultsViewProps> = ({
isMongoDB,
queryResults,
executeQueryDocumentsPage,
queryEditorContent,
databaseId,
containerId,
}) => {
export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResults, executeQueryDocumentsPage }) => {
const styles = useQueryTabStyles();
const [activeTab, setActiveTab] = useState<ResultsTabs>(ResultsTabs.Results);
const onTabSelect = useCallback((event: SelectTabEvent, data: SelectTabData) => {
setActiveTab(data.value as ResultsTabs);
}, []);
return (
<div data-test="QueryTab/ResultsPane/ResultsView" className={styles.queryResultsTabPanel}>
<TabList selectedValue={activeTab} onTabSelect={onTabSelect}>
@@ -885,13 +548,6 @@ export const ResultsView: React.FC<ResultsViewProps> = ({
>
Query Stats
</Tab>
<Tab
data-test="QueryTab/ResultsPane/ResultsView/IndexAdvisorTab"
id={ResultsTabs.IndexAdvisor}
value={ResultsTabs.IndexAdvisor}
>
Index Advisor
</Tab>
</TabList>
<div className={styles.queryResultsTabContentContainer}>
{activeTab === ResultsTabs.Results && (
@@ -902,30 +558,7 @@ export const ResultsView: React.FC<ResultsViewProps> = ({
/>
)}
{activeTab === ResultsTabs.QueryStats && <QueryStatsTab queryResults={queryResults} />}
{activeTab === ResultsTabs.IndexAdvisor && (
<IndexAdvisorTab
queryResults={queryResults}
queryEditorContent={queryEditorContent}
databaseId={databaseId}
containerId={containerId}
/>
)}
</div>
</div>
);
};
export interface IndexingPolicyStore {
indexingPolicies: { [containerId: string]: IndexingPolicy };
setIndexingPolicyFor: (containerId: string, indexingPolicy: IndexingPolicy) => void;
}
export const useIndexingPolicyStore = create<IndexingPolicyStore>((set) => ({
indexingPolicies: {},
setIndexingPolicyFor: (containerId, indexingPolicy) =>
set((state) => ({
indexingPolicies: {
...state.indexingPolicies,
[containerId]: { ...indexingPolicy },
},
})),
}));

View File

@@ -1,95 +0,0 @@
import { makeStyles } from "@fluentui/react-components";
export type IndexAdvisorStyles = ReturnType<typeof useIndexAdvisorStyles>;
export const useIndexAdvisorStyles = makeStyles({
indexAdvisorMessage: {
padding: "1rem",
fontSize: "1.2rem",
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
indexAdvisorSuccessIcon: {
width: "18px",
height: "18px",
borderRadius: "50%",
backgroundColor: "#107C10",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
indexAdvisorTitle: {
padding: "1rem",
fontSize: "1.3rem",
fontWeight: "bold",
},
indexAdvisorTable: {
display: "block",
alignItems: "center",
marginBottom: "7rem",
},
indexAdvisorGrid: {
display: "grid",
gridTemplateColumns: "30px 30px 1fr 50px 120px",
alignItems: "center",
gap: "15px",
fontWeight: "bold",
},
indexAdvisorCheckboxSpacer: {
width: "18px",
height: "18px",
},
indexAdvisorChevronSpacer: {
width: "24px",
},
indexAdvisorRowBold: {
fontWeight: "bold",
},
indexAdvisorRowNormal: {
fontWeight: "normal",
},
indexAdvisorRowImpactHeader: {
fontSize: 0,
},
indexAdvisorRowImpact: {
fontWeight: "normal",
},
indexAdvisorImpactDot: {
color: "#0078D4",
fontSize: "12px",
display: "inline-flex",
},
indexAdvisorImpactDots: {
display: "flex",
alignItems: "center",
gap: "4px",
},
indexAdvisorButtonBar: {
padding: "1rem",
marginTop: "-7rem",
flexWrap: "wrap",
},
indexAdvisorButtonSpinner: {
marginTop: "1rem",
minWidth: "320px",
minHeight: "40px",
display: "flex",
alignItems: "left",
justifyContent: "left",
marginLeft: "10rem",
},
indexAdvisorButton: {
backgroundColor: "#0078D4",
color: "white",
padding: "8px 16px",
border: "none",
borderRadius: "4px",
cursor: "pointer",
marginTop: "1rem",
fontSize: "1rem",
fontWeight: 500,
transition: "background 0.2s",
":hover": {
backgroundColor: "#005a9e",
},
},
});

View File

@@ -1,15 +0,0 @@
import create from "zustand";
interface QueryMetadataStore {
userQuery: string;
databaseId: string;
containerId: string;
setMetadata: (query1: string, db: string, container: string) => void;
}
export const useQueryMetadataStore = create<QueryMetadataStore>((set) => ({
userQuery: "",
databaseId: "",
containerId: "",
setMetadata: (query1, db, container) => set({ userQuery: query1, databaseId: db, containerId: container }),
}));

View File

@@ -435,6 +435,7 @@ export default class StoredProcedureTabComponent extends React.Component<
});
useCommandBar.getState().setContextButtons(this.getTabsButtons());
}, 100);
return createdResource;
},
(createError) => {

View File

@@ -1,7 +1,6 @@
import { Metric, onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals";
import { configContext } from "../ConfigContext";
import { Action } from "../Shared/Telemetry/TelemetryConstants";
import { traceFailure, traceMark, traceStart, traceSuccess } from "../Shared/Telemetry/TelemetryProcessor";
import { trackEvent } from "../Shared/appInsights";
import { userContext } from "../UserContext";
import MetricScenario, { reportHealthy, reportUnhealthy } from "./MetricEvents";
import { scenarioConfigs } from "./MetricScenarioConfigs";
@@ -84,13 +83,6 @@ class ScenarioMonitor {
ctx.phases.set(phase, { startMarkName: phaseStartMarkName });
});
traceMark(Action.MetricsScenario, {
event: "scenario_start",
scenario,
requiredPhases: config.requiredPhases.join(","),
timeoutMs: config.timeoutMs,
});
ctx.timeoutId = window.setTimeout(() => this.emit(ctx, false, true), config.timeoutMs);
this.contexts.set(scenario, ctx);
}
@@ -104,12 +96,6 @@ class ScenarioMonitor {
const startMarkName = `scenario_${scenario}_${phase}_start`;
performance.mark(startMarkName);
ctx.phases.set(phase, { startMarkName });
traceStart(Action.MetricsScenario, {
event: "phase_start",
scenario,
phase,
});
}
completePhase(scenario: MetricScenario, phase: MetricPhase) {
@@ -124,22 +110,6 @@ class ScenarioMonitor {
phaseCtx.endMarkName = endMarkName;
ctx.completed.add(phase);
const navigationStart = performance.timeOrigin;
const startEntry = performance.getEntriesByName(phaseCtx.startMarkName)[0];
const endEntry = performance.getEntriesByName(endMarkName)[0];
const endTimeISO = endEntry ? new Date(navigationStart + endEntry.startTime).toISOString() : undefined;
const durationMs = startEntry && endEntry ? endEntry.startTime - startEntry.startTime : undefined;
traceSuccess(Action.MetricsScenario, {
event: "phase_complete",
scenario,
phase,
endTimeISO,
durationMs,
completedCount: ctx.completed.size,
requiredCount: ctx.config.requiredPhases.length,
});
this.tryEmitIfReady(ctx);
}
@@ -163,14 +133,6 @@ class ScenarioMonitor {
// Build a snapshot with failure info
const failureSnapshot = this.buildSnapshot(ctx, { final: false, timedOut: false });
traceFailure(Action.MetricsScenario, {
event: "phase_fail",
scenario,
phase,
failedPhases: Array.from(ctx.failed).join(","),
completedPhases: Array.from(ctx.completed).join(","),
});
// Emit unhealthy immediately
this.emit(ctx, false, false, failureSnapshot);
}
@@ -229,22 +191,27 @@ class ScenarioMonitor {
// Build snapshot if not provided
const finalSnapshot = snapshot || this.buildSnapshot(ctx, { final: false, timedOut });
traceMark(Action.MetricsScenario, {
event: "scenario_end",
scenario: ctx.scenario,
healthy,
timedOut,
platform,
api,
durationMs: finalSnapshot.durationMs,
completedPhases: finalSnapshot.completed.join(","),
failedPhases: finalSnapshot.failedPhases?.join(","),
lcp: finalSnapshot.vitals?.lcp,
inp: finalSnapshot.vitals?.inp,
cls: finalSnapshot.vitals?.cls,
fcp: finalSnapshot.vitals?.fcp,
ttfb: finalSnapshot.vitals?.ttfb,
});
// Emit enriched telemetry with performance data
// TODO: Call portal backend metrics endpoint
trackEvent(
{ name: "MetricScenarioComplete" },
{
scenario: ctx.scenario,
healthy: healthy.toString(),
timedOut: timedOut.toString(),
platform,
api,
durationMs: finalSnapshot.durationMs.toString(),
completedPhases: finalSnapshot.completed.join(","),
failedPhases: finalSnapshot.failedPhases?.join(","),
lcp: finalSnapshot.vitals?.lcp?.toString(),
inp: finalSnapshot.vitals?.inp?.toString(),
cls: finalSnapshot.vitals?.cls?.toString(),
fcp: finalSnapshot.vitals?.fcp?.toString(),
ttfb: finalSnapshot.vitals?.ttfb?.toString(),
phaseTimings: JSON.stringify(finalSnapshot.phaseTimings),
},
);
// Call portal backend health metrics endpoint
if (healthy && !timedOut) {
@@ -260,16 +227,9 @@ class ScenarioMonitor {
private cleanupPerformanceEntries(ctx: InternalScenarioContext) {
performance.clearMarks(ctx.startMarkName);
ctx.config.requiredPhases.forEach((phase) => {
const phaseCtx = ctx.phases.get(phase);
if (phaseCtx?.startMarkName) {
performance.clearMarks(phaseCtx.startMarkName);
}
if (phaseCtx?.endMarkName) {
performance.clearMarks(phaseCtx.endMarkName);
}
performance.clearMarks(`scenario_${ctx.scenario}_${phase}_failed`);
performance.clearMeasures(`scenario_${ctx.scenario}_${phase}_duration`);
performance.clearMarks(`scenario_${ctx.scenario}_${phase}`);
});
performance.clearMeasures(`scenario_${ctx.scenario}_total`);
}
private buildSnapshot(

View File

@@ -2,7 +2,6 @@
// Some of the enums names are used in Fabric. Please do not rename them.
export enum Action {
CollapseTreeNode,
MetricsScenario,
CreateCollection, // Used in Fabric. Please do not rename.
CreateGlobalSecondaryIndex,
CreateDocument, // Used in Fabric. Please do not rename.

View File

@@ -2,9 +2,35 @@ 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-Allow-Headers": request.headers()["access-control-request-headers"] || "*",
Vary: "Origin",
},
});
return;
}
// Handle the actual GET/POST request
const response = await route.fetch({
headers: {
...route.request().headers(),
...request.headers(),
},
});

View File

@@ -1,163 +0,0 @@
# 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

@@ -378,9 +378,7 @@ type PanelOpenOptions = {
export enum CommandBarButton {
Save = "Save",
Execute = "Execute",
ExecuteQuery = "Execute Query",
UploadItem = "Upload Item",
}
/** Helper class that provides locator methods for DataExplorer components, on top of a Frame */
@@ -701,7 +699,6 @@ 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);
}

View File

@@ -1,145 +0,0 @@
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;

View File

@@ -1,135 +0,0 @@
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

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

View File

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

View File

@@ -1,51 +0,0 @@
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

@@ -83,33 +83,22 @@ test.describe("Container Copy", () => {
);
await accountItem.click();
// Verifying online or offline migration functionality
// Verifying online or offline checkbox functionality
/**
* This test verifies the functionality of the migration type radio that toggles between
* This test verifies the functionality of the migration type checkbox 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();
const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox");
await fluentUiCheckboxContainer.click();
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 fluentUiCheckboxContainer.click();
await panel.getByRole("button", { name: "Next" }).click();
await expect(panel.getByTestId("Panel:SelectSourceAndTargetContainers")).toBeVisible();
await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).not.toBeVisible();
@@ -295,9 +284,8 @@ test.describe("Container Copy", () => {
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 });
const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox");
await fluentUiCheckboxContainer.click();
await panel.getByRole("button", { name: "Next" }).click();

View File

@@ -1,363 +0,0 @@
# 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

@@ -1,275 +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 } 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();
});
});

View File

@@ -1,32 +0,0 @@
import { Frame, Locator, Page, test } from '@playwright/test';
import { ContainerCopy, getAccountName, TestAccount } from '../fx';
import { createMultipleTestContainers } from '../testData';
test.describe('Copy Job Seed', () => {
let page: Page;
let wrapper: Locator = null!;
let panel: Locator = null!;
let frame: Frame = null!;
let targetAccountName: string = "";
test.beforeAll("Copy Job - Before All", async ({ browser }) => {
await createMultipleTestContainers({ accountType: TestAccount.SQLContainerCopyOnly, containerCount: 3 });
page = await browser.newPage();
({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly));
targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
});
test.afterAll("Copy Job - After All", async () => {
await page.unroute(/.*/, (route) => route.continue());
await page.close();
});
test.afterEach("Copy Job - After Each", async () => {
await page.unroute(/.*/, (route) => route.continue());
});
test('Successfully create a copy job for offline migration', async ({ page }) => {
// generate code here.
});
test('Successfully create a copy job for online migration', async ({ page }) => {
// generate code here.
});
});

View File

@@ -1,18 +1,7 @@
import { expect, test } from "@playwright/test";
import { existsSync, mkdtempSync, rmdirSync, unlinkSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import { CommandBarButton, DataExplorer, DocumentsTab, ONE_MINUTE_MS, TestAccount } from "../fx";
import {
createTestSQLContainer,
itemsPerPartition,
partitionCount,
retry,
setPartitionKeys,
TestContainerContext,
TestData,
} from "../testData";
import { DataExplorer, DocumentsTab, TestAccount } from "../fx";
import { retry, setPartitionKeys } from "../testData";
import { documentTestCases } from "./testCases";
let explorer: DataExplorer = null!;
@@ -106,105 +95,3 @@ for (const { name, databaseId, containerId, documents } of documentTestCases) {
}
});
}
test.describe.serial("Upload Item", () => {
let context: TestContainerContext = null!;
let uploadDocumentDirPath: string = null!;
let uploadDocumentFilePath: string = null!;
test.beforeAll("Create Test database and open documents tab", async ({ browser }) => {
uploadDocumentDirPath = mkdtempSync(path.join(tmpdir(), "upload-document-"));
uploadDocumentFilePath = path.join(uploadDocumentDirPath, "uploadDocument.json");
const page = await browser.newPage();
context = await createTestSQLContainer();
explorer = await DataExplorer.open(page, TestAccount.SQL);
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
await containerNode.expand();
const containerMenuNode = await explorer.waitForContainerItemsNode(context.database.id, context.container.id);
await containerMenuNode.element.click();
// We need to click twice in order to remove a tooltip
await containerMenuNode.element.click();
});
test.afterAll("Delete Test Database and uploadDocument temp folder", async () => {
if (existsSync(uploadDocumentFilePath)) {
unlinkSync(uploadDocumentFilePath);
}
if (existsSync(uploadDocumentDirPath)) {
rmdirSync(uploadDocumentDirPath);
}
if (!process.env.CI) {
await context?.dispose();
}
});
test.afterEach("Close Upload Items panel if still open", async () => {
const closeUploadItemsPanelButton = explorer.frame.getByLabel("Close Upload Items");
if (await closeUploadItemsPanelButton.isVisible()) {
await closeUploadItemsPanelButton.click();
}
});
test("upload document", async () => {
// Create file to upload
const TestDataJsonString: string = JSON.stringify(TestData, null, 2);
writeFileSync(uploadDocumentFilePath, TestDataJsonString);
const uploadItemCommandBar = explorer.commandBarButton(CommandBarButton.UploadItem);
await uploadItemCommandBar.click();
// Select file to upload
await explorer.frame.setInputFiles("#importFileInput", uploadDocumentFilePath);
const uploadButton = explorer.frame.getByTestId("Panel/OkButton");
await uploadButton.click();
// Verify upload success message
const fileUploadStatusExpected: string = `${partitionCount * itemsPerPartition} created, 0 throttled, 0 errors`;
const fileUploadStatus = explorer.frame.getByTestId("file-upload-status");
await expect(fileUploadStatus).toContainText(fileUploadStatusExpected, {
timeout: ONE_MINUTE_MS,
});
// Select file to upload again
await explorer.frame.setInputFiles("#importFileInput", uploadDocumentFilePath);
await uploadButton.click();
// Verify upload failure message
const errorIcon = explorer.frame.getByRole("img", { name: "error" });
await expect(errorIcon).toBeVisible({ timeout: ONE_MINUTE_MS });
await expect(fileUploadStatus).toContainText(
`0 created, 0 throttled, ${partitionCount * itemsPerPartition} errors`,
{
timeout: ONE_MINUTE_MS,
},
);
});
test("upload invalid json", async () => {
// Create file to upload
let TestDataJsonString: string = JSON.stringify(TestData, null, 2);
// Remove the first '[' so that it becomes invalid json
TestDataJsonString = TestDataJsonString.substring(1);
writeFileSync(uploadDocumentFilePath, TestDataJsonString);
const uploadItemCommandBar = explorer.commandBarButton(CommandBarButton.UploadItem);
await uploadItemCommandBar.click();
// Select file to upload
await explorer.frame.setInputFiles("#importFileInput", uploadDocumentFilePath);
const uploadButton = explorer.frame.getByTestId("Panel/OkButton");
await uploadButton.click();
// Verify upload failure message
const fileUploadErrorList = explorer.frame.getByLabel("error list");
// The parsing error will show up differently in different browsers so just check for the word "JSON"
await expect(fileUploadErrorList).toContainText("JSON", {
timeout: ONE_MINUTE_MS,
});
});
});

View File

@@ -1,145 +0,0 @@
import { expect, test, type Page } from "@playwright/test";
import { CommandBarButton, DataExplorer, TestAccount } from "../fx";
import { createTestSQLContainer, TestContainerContext } from "../testData";
// Test container context for setup and cleanup
let testContainer: TestContainerContext;
let DATABASE_ID: string;
let CONTAINER_ID: string;
// Set up test database and container with data before all tests
test.beforeAll(async () => {
testContainer = await createTestSQLContainer(true);
DATABASE_ID = testContainer.database.id;
CONTAINER_ID = testContainer.container.id;
});
// Clean up test database after all tests
test.afterAll(async () => {
if (testContainer) {
await testContainer.dispose();
}
});
// Helper function to set up query tab and navigate to Index Advisor
async function setupIndexAdvisorTab(page: Page, customQuery?: string) {
const explorer = await DataExplorer.open(page, TestAccount.SQL);
const databaseNode = await explorer.waitForNode(DATABASE_ID);
await databaseNode.expand();
await page.waitForTimeout(2000);
const containerNode = await explorer.waitForNode(`${DATABASE_ID}/${CONTAINER_ID}`);
await containerNode.openContextMenu();
await containerNode.contextMenuItem("New SQL Query").click();
await page.waitForTimeout(2000);
const queryTab = explorer.queryTab("tab0");
const queryEditor = queryTab.editor();
await queryEditor.locator.waitFor({ timeout: 30 * 1000 });
await queryTab.executeCTA.waitFor();
if (customQuery) {
await queryEditor.locator.click();
await queryEditor.setText(customQuery);
}
const executeQueryButton = explorer.commandBarButton(CommandBarButton.ExecuteQuery);
await executeQueryButton.click();
await expect(queryTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 });
const indexAdvisorTab = queryTab.resultsView.getByTestId("QueryTab/ResultsPane/ResultsView/IndexAdvisorTab");
await indexAdvisorTab.click();
await page.waitForTimeout(2000);
return { explorer, queryTab, indexAdvisorTab };
}
test("Index Advisor tab loads without errors", async ({ page }) => {
const { indexAdvisorTab } = await setupIndexAdvisorTab(page);
await expect(indexAdvisorTab).toHaveAttribute("aria-selected", "true");
});
test("Verify UI sections are collapsible", async ({ page }) => {
const { explorer } = await setupIndexAdvisorTab(page);
// Verify both section headers exist
const includedHeader = explorer.frame.getByText("Included in Current Policy", { exact: true });
const notIncludedHeader = explorer.frame.getByText("Not Included in Current Policy", { exact: true });
await expect(includedHeader).toBeVisible();
await expect(notIncludedHeader).toBeVisible();
// Test collapsibility by checking if chevron/arrow icon changes state
// Both sections should be expandable/collapsible regardless of content
await includedHeader.click();
await page.waitForTimeout(300);
await includedHeader.click();
await page.waitForTimeout(300);
await notIncludedHeader.click();
await page.waitForTimeout(300);
await notIncludedHeader.click();
await page.waitForTimeout(300);
});
test("Verify SDK response structure - Case 1: Empty response", async ({ page }) => {
const { explorer } = await setupIndexAdvisorTab(page);
// Verify both section headers still exist even with no data
await expect(explorer.frame.getByText("Included in Current Policy", { exact: true })).toBeVisible();
await expect(explorer.frame.getByText("Not Included in Current Policy", { exact: true })).toBeVisible();
// Verify table headers
const table = explorer.frame.locator("table");
await expect(table.getByText("Index", { exact: true })).toBeVisible();
await expect(table.getByText("Estimated Impact", { exact: true })).toBeVisible();
// Verify "Update Indexing Policy" button is NOT visible when there are no potential indexes
const updateButton = explorer.frame.getByRole("button", { name: /Update Indexing Policy/i });
await expect(updateButton).not.toBeVisible();
});
test("Verify index suggestions and apply potential index", async ({ page }) => {
const customQuery = 'SELECT * FROM c WHERE c.partitionKey = "partition_1" ORDER BY c.randomData';
const { explorer } = await setupIndexAdvisorTab(page, customQuery);
// Wait for Index Advisor to process the query
await page.waitForTimeout(2000);
// Verify "Not Included in Current Policy" section has suggestions
const notIncludedHeader = explorer.frame.getByText("Not Included in Current Policy", { exact: true });
await expect(notIncludedHeader).toBeVisible();
// Find the checkbox for the suggested composite index
// The composite index should be /partitionKey ASC, /randomData ASC
const checkboxes = explorer.frame.locator('input[type="checkbox"]');
const checkboxCount = await checkboxes.count();
// Should have at least one checkbox for the potential index
expect(checkboxCount).toBeGreaterThan(0);
// Select the first checkbox (the high-impact composite index)
await checkboxes.first().check();
await page.waitForTimeout(500);
// Verify "Update Indexing Policy" button becomes visible
const updateButton = explorer.frame.getByRole("button", { name: /Update Indexing Policy/i });
await expect(updateButton).toBeVisible();
// Click the "Update Indexing Policy" button
await updateButton.click();
await page.waitForTimeout(1000);
// Verify success message appears
const successMessage = explorer.frame.getByText(/Your indexing policy has been updated with the new included paths/i);
await expect(successMessage).toBeVisible();
// Verify the message mentions reviewing changes in Scale & Settings
const reviewMessage = explorer.frame.getByText(/You may review the changes in Scale & Settings/i);
await expect(reviewMessage).toBeVisible();
// Verify the checkmark icon is shown
const checkmarkIcon = explorer.frame.locator('[data-icon-name="CheckMark"]');
await expect(checkmarkIcon).toBeVisible();
});

View File

@@ -1,108 +0,0 @@
import { expect, Page, test } from "@playwright/test";
import * as DataModels from "../../../src/Contracts/DataModels";
import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx";
import { createTestSQLContainer, TestContainerContext } from "../../testData";
test.describe("Computed Properties", () => {
let context: TestContainerContext = null!;
let explorer: DataExplorer = null!;
test.beforeAll("Create Test Database", async () => {
context = await createTestSQLContainer();
});
test.beforeEach("Open Settings tab under Scale & Settings", async ({ page }) => {
explorer = await DataExplorer.open(page, TestAccount.SQL);
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
await containerNode.expand();
// Click Scale & Settings and open Settings tab
await explorer.openScaleAndSettings(context);
const computedPropertiesTab = explorer.frame.getByTestId("settings-tab-header/ComputedPropertiesTab");
await computedPropertiesTab.click();
});
test.afterAll("Delete Test Database", async () => {
await context?.dispose();
});
test("Add valid computed property", async ({ page }) => {
await clearComputedPropertiesTextBoxContent({ page });
// Create computed property
const computedProperties: DataModels.ComputedProperties = [
{
name: "cp_lowerName",
query: "SELECT VALUE LOWER(c.name) FROM c",
},
];
const computedPropertiesString: string = JSON.stringify(computedProperties);
await page.keyboard.type(computedPropertiesString);
// Save changes
const saveButton = explorer.commandBarButton(CommandBarButton.Save);
await expect(saveButton).toBeEnabled();
await saveButton.click();
await expect(explorer.getConsoleHeaderStatus()).toContainText(
`Successfully updated container ${context.container.id}`,
{
timeout: ONE_MINUTE_MS,
},
);
});
test("Add computed property with invalid query", async ({ page }) => {
await clearComputedPropertiesTextBoxContent({ page });
// Create computed property with no VALUE keyword in query
const computedProperties: DataModels.ComputedProperties = [
{
name: "cp_lowerName",
query: "SELECT LOWER(c.name) FROM c",
},
];
const computedPropertiesString: string = JSON.stringify(computedProperties);
await page.keyboard.type(computedPropertiesString);
// Save changes
const saveButton = explorer.commandBarButton(CommandBarButton.Save);
await expect(saveButton).toBeEnabled();
await saveButton.click();
await expect(explorer.getConsoleHeaderStatus()).toContainText(
`Failed to update container ${context.container.id}`,
{
timeout: ONE_MINUTE_MS,
},
);
});
test("Add computed property with invalid json", async ({ page }) => {
await clearComputedPropertiesTextBoxContent({ page });
// Create computed property with no VALUE keyword in query
const computedProperties: DataModels.ComputedProperties = [
{
name: "cp_lowerName",
query: "SELECT LOWER(c.name) FROM c",
},
];
const computedPropertiesString: string = JSON.stringify(computedProperties);
await page.keyboard.type(computedPropertiesString + "]");
// Save button should remain disabled due to invalid json
const saveButton = explorer.commandBarButton(CommandBarButton.Save);
await expect(saveButton).toBeDisabled();
});
const clearComputedPropertiesTextBoxContent = async ({ page }: { page: Page }): Promise<void> => {
// Get computed properties text box
await explorer.frame.waitForSelector(".monaco-scrollable-element", { state: "visible" });
const computedPropertiesEditor = explorer.frame.getByTestId("computed-properties-editor");
await computedPropertiesEditor.click();
// Clear existing content (Ctrl+A + Backspace does not work with webkit)
for (let i = 0; i < 100; i++) {
await page.keyboard.press("Backspace");
}
};
});

View File

@@ -11,7 +11,7 @@ test.describe("Settings under Scale & Settings", () => {
const page = await browser.newPage();
explorer = await DataExplorer.open(page, TestAccount.SQL);
// Click Scale & Settings and open Settings tab
// Click Scale & Settings and open Scale tab
await explorer.openScaleAndSettings(context);
const settingsTab = explorer.frame.getByTestId("settings-tab-header/SubSettingsTab");
await settingsTab.click();
@@ -53,28 +53,4 @@ test.describe("Settings under Scale & Settings", () => {
},
);
});
test("Set Geospatial Config to Geometry then Geography", async () => {
const geometryRadioButton = explorer.frame.getByRole("radio", { name: "geometry-option" });
await geometryRadioButton.click();
await explorer.commandBarButton(CommandBarButton.Save).click();
await expect(explorer.getConsoleHeaderStatus()).toContainText(
`Successfully updated container ${context.container.id}`,
{
timeout: ONE_MINUTE_MS,
},
);
const geographyRadioButton = explorer.frame.getByRole("radio", { name: "geography-option" });
await geographyRadioButton.click();
await explorer.commandBarButton(CommandBarButton.Save).click();
await expect(explorer.getConsoleHeaderStatus()).toContainText(
`Successfully updated container ${context.container.id}`,
{
timeout: ONE_MINUTE_MS,
},
);
});
});

View File

@@ -1,78 +0,0 @@
import { expect, test } from "@playwright/test";
import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx";
import { createTestSQLContainer, TestContainerContext } from "../../testData";
test.describe("Stored Procedures", () => {
let context: TestContainerContext = null!;
let explorer: DataExplorer = null!;
test.beforeAll("Create Test Database", async () => {
context = await createTestSQLContainer();
});
test.beforeEach("Open container", async ({ page }) => {
explorer = await DataExplorer.open(page, TestAccount.SQL);
});
test.afterAll("Delete Test Database", async () => {
await context?.dispose();
});
test("Add, execute, and delete stored procedure", async ({ page }, testInfo) => {
void page;
// Open container context menu and click New Stored Procedure
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
await containerNode.openContextMenu();
await containerNode.contextMenuItem("New Stored Procedure").click();
// Type stored procedure id and use stock procedure
const storedProcedureIdTextBox = explorer.frame.getByLabel("Stored procedure id");
await storedProcedureIdTextBox.isVisible();
const storedProcedureName = `stored-procedure-${testInfo.testId}`;
await storedProcedureIdTextBox.fill(storedProcedureName);
const saveButton = explorer.commandBarButton(CommandBarButton.Save);
await expect(saveButton).toBeEnabled();
await saveButton.click();
await expect(explorer.getConsoleHeaderStatus()).toContainText(
`Successfully created stored procedure ${storedProcedureName}`,
{
timeout: 2 * ONE_MINUTE_MS,
},
);
// Execute stored procedure
const executeButton = explorer.commandBarButton(CommandBarButton.Execute);
await executeButton.click();
const executeSidePanelButton = explorer.frame.getByTestId("Panel/OkButton");
await executeSidePanelButton.click();
const executeStoredProcedureResult = explorer.frame.getByLabel("Execute stored procedure result");
await expect(executeStoredProcedureResult).toBeAttached({
timeout: ONE_MINUTE_MS,
});
// Delete stored procedure
await containerNode.expand();
const storedProceduresNode = await explorer.waitForNode(
`${context.database.id}/${context.container.id}/Stored Procedures`,
);
await storedProceduresNode.expand();
const storedProcedureNode = await explorer.waitForNode(
`${context.database.id}/${context.container.id}/Stored Procedures/${storedProcedureName}`,
);
await storedProcedureNode.openContextMenu();
await storedProcedureNode.contextMenuItem("Delete Stored Procedure").click();
const deleteStoredProcedureButton = explorer.frame.getByTestId("DialogButton:Delete");
await deleteStoredProcedureButton.click();
await expect(explorer.getConsoleHeaderStatus()).toContainText(
`Successfully deleted stored procedure ${storedProcedureName}`,
{
timeout: ONE_MINUTE_MS,
},
);
});
});

View File

@@ -1,80 +0,0 @@
import { expect, test } from "@playwright/test";
import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx";
import { createTestSQLContainer, TestContainerContext } from "../../testData";
test.describe("Triggers", () => {
let context: TestContainerContext = null!;
let explorer: DataExplorer = null!;
const triggerBody = `function validateToDoItemTimestamp() {
var context = getContext();
var request = context.getRequest();
var itemToCreate = request.getBody();
if (!("timestamp" in itemToCreate)) {
var ts = new Date();
itemToCreate["timestamp"] = ts.getTime();
}
request.setBody(itemToCreate);
}`;
test.beforeAll("Create Test Database", async () => {
context = await createTestSQLContainer();
});
test.beforeEach("Open container", async ({ page }) => {
explorer = await DataExplorer.open(page, TestAccount.SQL);
});
if (!process.env.CI) {
test.afterAll("Delete Test Database", async () => {
await context?.dispose();
});
}
test("Add and delete trigger", async ({ page }, testInfo) => {
// Open container context menu and click New Trigger
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
await containerNode.openContextMenu();
await containerNode.contextMenuItem("New Trigger").click();
// Assign Trigger id
const triggerIdTextBox = explorer.frame.getByLabel("Trigger Id");
const triggerId: string = `validateItemTimestamp-${testInfo.testId}`;
await triggerIdTextBox.fill(triggerId);
// Create Trigger body that validates item timestamp
const triggerBodyTextArea = explorer.frame.getByTestId("EditorReact/Host/Loaded");
await triggerBodyTextArea.click();
// Clear existing content
const isMac: boolean = process.platform === "darwin";
await page.keyboard.press(isMac ? "Meta+A" : "Control+A");
await page.keyboard.press("Backspace");
await page.keyboard.type(triggerBody);
// Save changes
const saveButton = explorer.commandBarButton(CommandBarButton.Save);
await saveButton.click();
await expect(explorer.getConsoleHeaderStatus()).toContainText(`Successfully created trigger ${triggerId}`, {
timeout: 2 * ONE_MINUTE_MS,
});
// Delete Trigger
await containerNode.expand();
const triggersNode = await explorer.waitForNode(`${context.database.id}/${context.container.id}/Triggers`);
await triggersNode.expand();
const triggerNode = await explorer.waitForNode(
`${context.database.id}/${context.container.id}/Triggers/${triggerId}`,
);
await triggerNode.openContextMenu();
await triggerNode.contextMenuItem("Delete Trigger").click();
const deleteTriggerButton = explorer.frame.getByTestId("DialogButton:Delete");
await deleteTriggerButton.click();
await expect(explorer.getConsoleHeaderStatus()).toContainText(`Successfully deleted trigger ${triggerId}`, {
timeout: ONE_MINUTE_MS,
});
});
});

View File

@@ -1,82 +0,0 @@
import { expect, test } from "@playwright/test";
import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx";
import { createTestSQLContainer, TestContainerContext } from "../../testData";
test.describe("User Defined Functions", () => {
let context: TestContainerContext = null!;
let explorer: DataExplorer = null!;
const udfBody: string = `function extractDocumentId(doc) {
return {
id: doc.id
};
}`;
test.beforeAll("Create Test Database", async () => {
context = await createTestSQLContainer();
});
test.beforeEach("Open container", async ({ page }) => {
explorer = await DataExplorer.open(page, TestAccount.SQL);
});
if (!process.env.CI) {
test.afterAll("Delete Test Database", async () => {
await context?.dispose();
});
}
test("Add, execute, and delete user defined function", async ({ page }, testInfo) => {
// Open container context menu and click New UDF
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
await containerNode.openContextMenu();
await containerNode.contextMenuItem("New UDF").click();
// Assign UDF id
const udfIdTextBox = explorer.frame.getByLabel("User Defined Function Id");
const udfId: string = `extractDocumentId-${testInfo.testId}`;
await udfIdTextBox.fill(udfId);
// Create UDF body that extracts the document id from a document
const udfBodyTextArea = explorer.frame.getByTestId("EditorReact/Host/Loaded");
await udfBodyTextArea.click();
// Clear existing content
const isMac: boolean = process.platform === "darwin";
await page.keyboard.press(isMac ? "Meta+A" : "Control+A");
await page.keyboard.press("Backspace");
await page.keyboard.type(udfBody);
// Save changes
const saveButton = explorer.commandBarButton(CommandBarButton.Save);
await expect(saveButton).toBeEnabled();
await saveButton.click();
await expect(explorer.getConsoleHeaderStatus()).toContainText(
`Successfully created user defined function ${udfId}`,
{
timeout: 2 * ONE_MINUTE_MS,
},
);
// Delete UDF
await containerNode.expand();
const udfsNode = await explorer.waitForNode(
`${context.database.id}/${context.container.id}/User Defined Functions`,
);
await udfsNode.expand();
const udfNode = await explorer.waitForNode(
`${context.database.id}/${context.container.id}/User Defined Functions/${udfId}`,
);
await udfNode.openContextMenu();
await udfNode.contextMenuItem("Delete User Defined Function").click();
const deleteUserDefinedFunctionButton = explorer.frame.getByTestId("DialogButton:Delete");
await deleteUserDefinedFunctionButton.click();
await expect(explorer.getConsoleHeaderStatus()).toContainText(
`Successfully deleted user defined function ${udfId}`,
{
timeout: ONE_MINUTE_MS,
},
);
});
});

View File

@@ -37,35 +37,27 @@ export interface PartitionKey {
value: string | null;
}
export const partitionCount = 4;
const partitionCount = 4;
// If we increase this number, we need to split bulk creates into multiple batches.
// Bulk operations are limited to 100 items per partition.
export const itemsPerPartition = 100;
const itemsPerPartition = 100;
function createTestItems(): TestItem[] {
const items: TestItem[] = [];
for (let i = 0; i < partitionCount; i++) {
for (let j = 0; j < itemsPerPartition; j++) {
const id = createSafeRandomString(32);
const id = crypto.randomBytes(32).toString("base64");
items.push({
id,
partitionKey: `partition_${i}`,
randomData: createSafeRandomString(32),
randomData: crypto.randomBytes(32).toString("base64"),
});
}
}
return items;
}
// Document IDs cannot contain '/', '\', or '#'
function createSafeRandomString(byteLength: number): string {
return crypto
.randomBytes(byteLength)
.toString("base64")
.replace(/[/\\#]/g, "_");
}
export const TestData: TestItem[] = createTestItems();
export class TestContainerContext {

View File

@@ -2,12 +2,10 @@ const { AzureCliCredential } = require("@azure/identity");
const { CosmosDBManagementClient } = require("@azure/arm-cosmosdb");
const ms = require("ms");
// const subscriptionId = process.env["AZURE_SUBSCRIPTION_ID"];
// const resourceGroupName = "de-e2e-tests";
const subscriptionId = "074d02eb-4d74-486a-b299-b262264d1536";
const resourceGroupName = "bchoudhury-e2e-testing";
const subscriptionId = process.env["AZURE_SUBSCRIPTION_ID"];
const resourceGroupName = "de-e2e-tests";
const thirtyMinutesAgo = new Date(Date.now() - 1000 * 60 * 5).getTime();
const thirtyMinutesAgo = new Date(Date.now() - 1000 * 60 * 30).getTime();
function friendlyTime(date) {
try {
@@ -20,7 +18,7 @@ function friendlyTime(date) {
async function main() {
const credentials = new AzureCliCredential();
const client = new CosmosDBManagementClient(credentials, subscriptionId);
const accounts = await client.databaseAccounts.listByResourceGroup(resourceGroupName);
const accounts = await client.databaseAccounts.list(resourceGroupName);
for (const account of accounts) {
if (account.name.endsWith("-readonly")) {
console.log(`SKIPPED: ${account.name}`);