Update Playwright, improve E2E test reliability, add scripts to deploy test resources (#1857)

This commit is contained in:
Ashley Stanton-Nurse 2024-06-05 12:46:32 -07:00 committed by GitHub
parent 736731474f
commit 417ef899f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
56 changed files with 1176 additions and 2117 deletions

10
.editorconfig Normal file
View File

@ -0,0 +1,10 @@
# NOTE: Prettier reads EditorConfig settings, so be careful adjusting settings here and assuming they'll only affect your editor ;).
# top-most EditorConfig file
root = true
[*.yml]
indent_size = 2
[*.{js,jsx,ts,tsx}]
indent_size = 2

View File

@ -1,3 +1,5 @@
playwright.config.ts
**/node_modules/
src/**/__mocks__/**/*
dist/

View File

@ -104,79 +104,6 @@ jobs:
run: az storage blob upload -c '$web' -f ./preview/config.json --account-name cosmosexplorerpreview --name "${{github.event.pull_request.head.sha || github.sha}}/config.json" --account-key="${PREVIEW_STORAGE_KEY}" --overwrite true
env:
PREVIEW_STORAGE_KEY: ${{ secrets.PREVIEW_STORAGE_KEY }}
endtoendemulator:
name: "End To End Emulator Tests"
# Temporarily disabled. This test needs to be rewritten in playwright
if: false
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 18.x
uses: actions/setup-node@v4
with:
node-version: 18.x
- uses: southpolesteve/cosmos-emulator-github-action@v1
- name: End to End Tests
run: |
npm ci
npm start &
npm run wait-for-server
npx jest -c ./jest.config.e2e.js --detectOpenHandles test/sql/container.spec.ts
shell: bash
env:
DATA_EXPLORER_ENDPOINT: "https://localhost:1234/explorer.html?platform=Emulator"
PLATFORM: "Emulator"
NODE_TLS_REJECT_UNAUTHORIZED: 0
- uses: actions/upload-artifact@v3
if: failure()
with:
name: screenshots
path: failed-*
endtoend:
name: "E2E"
runs-on: ubuntu-latest
env:
NODE_TLS_REJECT_UNAUTHORIZED: 0
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
strategy:
fail-fast: false
matrix:
test-file:
- ./test/cassandra/container.spec.ts
- ./test/graph/container.spec.ts
- ./test/sql/container.spec.ts
- ./test/mongo/container.spec.ts
- ./test/mongo/container32.spec.ts
- ./test/selfServe/selfServeExample.spec.ts
- ./test/sql/resourceToken.spec.ts
- ./test/tables/container.spec.ts
steps:
- uses: actions/checkout@v4
- name: "Az CLI login"
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Use Node.js 18.x
uses: actions/setup-node@v4
with:
node-version: 18.x
- run: npm ci
- run: npm start &
- run: npm run wait-for-server
- name: ${{ matrix['test-file'] }}
run: |
# Run tests up to three times
for i in $(seq 1 3); do npx jest -c ./jest.config.playwright.js ${{ matrix['test-file'] }} && s=0 && break || s=$? && sleep 1; done; (exit $s)
shell: bash
- uses: actions/upload-artifact@v3
if: failure()
with:
name: screenshots
path: screenshots/
nuget:
name: Publish Nuget
if: github.ref == 'refs/heads/master' || contains(github.ref, 'hotfix/') || contains(github.ref, 'release/')
@ -226,3 +153,70 @@ jobs:
name: packages
with:
path: "*.nupkg"
playwright-tests:
name: "Run Playwright Tests (Shard ${{ matrix.shardIndex }} of ${{ matrix.shardTotal }})"
runs-on: ubuntu-latest
env:
NODE_TLS_REJECT_UNAUTHORIZED: 0
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [8]
steps:
- uses: actions/checkout@v4
- name: "Az CLI login"
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Use Node.js 18.x
uses: actions/setup-node@v4
with:
node-version: 18.x
- run: npm ci
- run: npx playwright install --with-deps
- name: Run test shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}}
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
- name: Upload blob report to GitHub Actions Artifacts
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: blob-report-${{ matrix.shardIndex }}
path: blob-report
retention-days: 1
merge-playwright-reports:
name: "Merge Playwright Reports"
# Merge reports after playwright-tests, even if some shards have failed
if: ${{ !cancelled() }}
needs: [playwright-tests]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Download blob reports from GitHub Actions Artifacts
uses: actions/download-artifact@v4
with:
path: all-blob-reports
pattern: blob-report-*
merge-multiple: true
- name: Merge into HTML Report
run: npx playwright merge-reports --reporter html ./all-blob-reports
- name: Upload HTML report
uses: actions/upload-artifact@v4
with:
name: html-report--attempt-${{ github.run_attempt }}
path: playwright-report
retention-days: 14

4
.gitignore vendored
View File

@ -17,3 +17,7 @@ Contracts/*
failure.png
screenshots/*
GettingStarted-ignore*.ipynb
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

View File

@ -1,13 +0,0 @@
const isCI = require("is-ci");
module.exports = {
exitOnPageError: false,
launchOptions: {
headless: isCI,
slowMo: 10,
timeout: 60000,
},
contextOptions: {
ignoreHTTPSErrors: true,
},
};

View File

@ -1,7 +0,0 @@
module.exports = {
preset: "jest-playwright-preset",
testMatch: ["<rootDir>/test/**/*.spec.[jt]s?(x)"],
setupFiles: ["dotenv/config"],
testEnvironment: "./test/playwrightEnv.js",
setupFilesAfterEnv: ["expect-playwright"],
};

1426
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -117,6 +117,7 @@
"@babel/preset-env": "7.9.0",
"@babel/preset-react": "7.9.4",
"@babel/preset-typescript": "7.9.0",
"@playwright/test": "1.44.0",
"@testing-library/react": "11.2.3",
"@types/applicationinsights-js": "1.0.7",
"@types/codemirror": "0.0.56",
@ -161,7 +162,6 @@
"eslint-plugin-no-null": "1.0.2",
"eslint-plugin-prefer-arrow": "1.2.3",
"eslint-plugin-react-hooks": "4.6.0",
"expect-playwright": "0.3.3",
"fast-glob": "3.2.5",
"fs-extra": "7.0.0",
"html-inline-css-webpack-plugin": "1.11.2",
@ -170,7 +170,6 @@
"html-webpack-plugin": "5.5.3",
"jest": "26.6.3",
"jest-canvas-mock": "2.3.1",
"jest-playwright-preset": "1.5.1",
"jest-react-hooks-shallow": "1.5.1",
"jest-trx-results-processor": "0.0.7",
"less": "3.8.1",
@ -179,7 +178,6 @@
"mini-css-extract-plugin": "2.1.0",
"monaco-editor-webpack-plugin": "7.1.0",
"node-fetch": "2.6.7",
"playwright": "1.13.0",
"prettier": "3.0.3",
"process": "0.11.10",
"querystring-es3": "0.2.1",

53
playwright.config.ts Normal file
View File

@ -0,0 +1,53 @@
import { defineConfig, devices } from '@playwright/test';
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: 'test',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 3 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? 'blob' : 'html',
timeout: 10 * 60 * 1000,
use: {
actionTimeout: 5 * 60 * 1000,
trace: 'off',
video: 'off',
screenshot: 'on',
testIdAttribute: 'data-test',
contextOptions: {
ignoreHTTPSErrors: true,
},
},
expect: {
timeout: 5 * 60 * 1000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'npm run start',
url: 'https://127.0.0.1:1234/_ready',
timeout: 120 * 1000,
ignoreHTTPSErrors: true,
reuseExistingServer: !process.env.CI,
},
});

View File

@ -166,6 +166,7 @@ export class LegacyTreeNodeComponent extends React.Component<
return (
<div
data-test={`Tree/TreeNode:${node.label}`}
className={`${this.props.node.className || ""} main${generation} nodeItem ${showSelected ? "selected" : ""}`}
onClick={(event: React.MouseEvent<HTMLDivElement>) => this.onNodeClick(event, node)}
onKeyPress={(event: React.KeyboardEvent<HTMLDivElement>) => this.onNodeKeyPress(event, node)}
@ -174,9 +175,9 @@ export class LegacyTreeNodeComponent extends React.Component<
>
<div
className={`treeNodeHeader ${this.state.isMenuShowing ? "showingMenu" : ""}`}
data-test={`Tree/TreeNode/Header:${node.label}`}
style={headerStyle}
tabIndex={node.children ? -1 : 0}
data-test={node.label}
>
{this.renderCollapseExpandIcon(node)}
{node.iconSrc && <img className="nodeIcon" src={node.iconSrc} alt="" />}
@ -264,7 +265,7 @@ export class LegacyTreeNodeComponent extends React.Component<
onMenuDismissed: (contextualMenu?: IContextualMenuProps) => this.setState({ isMenuShowing: false }),
contextualMenuItemAs: (props: IContextualMenuItemProps) => (
<div
data-test={`treeComponentMenuItemContainer`}
data-test={`Tree/TreeNode/MenuItem:${props.item.text}`}
className="treeComponentMenuItemContainer"
onContextMenu={(e) => e.target.dispatchEvent(LegacyTreeNodeComponent.createClickEvent())}
>

View File

@ -147,6 +147,7 @@ export const TreeNodeComponent: React.FC<TreeNodeComponentProps> = ({
const treeItem = (
<TreeItem
data-test={`TreeNodeContainer:${treeNodeId}`}
value={treeNodeId}
itemType={isBranch ? "branch" : "leaf"}
style={{ height: "100%" }}

View File

@ -36,13 +36,14 @@ exports[`LegacyTreeComponent renders a simple tree 1`] = `
exports[`LegacyTreeNodeComponent does not render children by default 1`] = `
<div
className=" main2 nodeItem "
data-test="Tree/TreeNode:label"
onClick={[Function]}
onKeyPress={[Function]}
role="treeitem"
>
<div
className="treeNodeHeader "
data-test="label"
data-test="Tree/TreeNode/Header:label"
style={
Object {
"paddingLeft": 9,
@ -138,6 +139,7 @@ exports[`LegacyTreeNodeComponent does not render children by default 1`] = `
exports[`LegacyTreeNodeComponent renders a simple node (sorted children, expanded) 1`] = `
<div
className="nodeClassname main12 nodeItem "
data-test="Tree/TreeNode:label"
id="id"
onClick={[Function]}
onKeyPress={[Function]}
@ -145,7 +147,7 @@ exports[`LegacyTreeNodeComponent renders a simple node (sorted children, expande
>
<div
className="treeNodeHeader "
data-test="label"
data-test="Tree/TreeNode/Header:label"
style={
Object {
"paddingLeft": 23,
@ -290,13 +292,14 @@ exports[`LegacyTreeNodeComponent renders a simple node (sorted children, expande
exports[`LegacyTreeNodeComponent renders loading icon 1`] = `
<div
className=" main2 nodeItem "
data-test="Tree/TreeNode:label"
onClick={[Function]}
onKeyPress={[Function]}
role="treeitem"
>
<div
className="treeNodeHeader "
data-test="label"
data-test="Tree/TreeNode/Header:label"
style={
Object {
"paddingLeft": 9,
@ -363,6 +366,7 @@ exports[`LegacyTreeNodeComponent renders loading icon 1`] = `
exports[`LegacyTreeNodeComponent renders sorted children, expanded, leaves and parents separated 1`] = `
<div
className="nodeClassname main12 nodeItem "
data-test="Tree/TreeNode:label"
id="id"
onClick={[Function]}
onKeyPress={[Function]}
@ -370,7 +374,7 @@ exports[`LegacyTreeNodeComponent renders sorted children, expanded, leaves and p
>
<div
className="treeNodeHeader "
data-test="label"
data-test="Tree/TreeNode/Header:label"
style={
Object {
"paddingLeft": 23,
@ -534,13 +538,14 @@ exports[`LegacyTreeNodeComponent renders sorted children, expanded, leaves and p
exports[`LegacyTreeNodeComponent renders unsorted children by default 1`] = `
<div
className=" main2 nodeItem "
data-test="Tree/TreeNode:label"
onClick={[Function]}
onKeyPress={[Function]}
role="treeitem"
>
<div
className="treeNodeHeader "
data-test="label"
data-test="Tree/TreeNode/Header:label"
style={
Object {
"paddingLeft": 9,

View File

@ -2,6 +2,7 @@
exports[`TreeNodeComponent does not render children if the node is loading 1`] = `
<TreeItem
data-test="TreeNodeContainer:root"
itemType="branch"
onOpenChange={[Function]}
style={
@ -114,6 +115,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
treeNodeId="root"
>
<TreeItem
data-test="TreeNodeContainer:root"
itemType="branch"
onOpenChange={[Function]}
style={
@ -129,6 +131,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-selected="false"
className="fui-TreeItem r1hiwysc"
data-fui-tree-item-value="root"
data-test="TreeNodeContainer:root"
onChange={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
@ -228,6 +231,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-selected="false"
class="fui-TreeItem r1hiwysc"
data-fui-tree-item-value="root"
data-test="TreeNodeContainer:root"
role="treeitem"
style="height: 100%;"
tabindex="-1"
@ -282,6 +286,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-selected="false"
class="fui-TreeItem r1hiwysc ___jer8zr0_6no3ah0 f10bgyvd"
data-fui-tree-item-value="root/child1Label"
data-test="TreeNodeContainer:root/child1Label"
role="treeitem"
style="height: 100%;"
tabindex="0"
@ -333,6 +338,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-selected="false"
class="fui-TreeItem r1hiwysc ___jer8zr0_6no3ah0 f10bgyvd"
data-fui-tree-item-value="root/child2LoadingLabel"
data-test="TreeNodeContainer:root/child2LoadingLabel"
role="treeitem"
style="height: 100%;"
tabindex="-1"
@ -383,6 +389,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-selected="false"
class="fui-TreeItem r1hiwysc ___jer8zr0_6no3ah0 f10bgyvd"
data-fui-tree-item-value="root/child3ExpandingLabel"
data-test="TreeNodeContainer:root/child3ExpandingLabel"
role="treeitem"
style="height: 100%;"
tabindex="-1"
@ -637,6 +644,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
treeNodeId="root/child1Label"
>
<TreeItem
data-test="TreeNodeContainer:root/child1Label"
itemType="branch"
onOpenChange={[Function]}
style={
@ -652,6 +660,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-selected="false"
className="fui-TreeItem r1hiwysc ___jer8zr0_6no3ah0 f10bgyvd"
data-fui-tree-item-value="root/child1Label"
data-test="TreeNodeContainer:root/child1Label"
onChange={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
@ -751,6 +760,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-selected="false"
class="fui-TreeItem r1hiwysc ___jer8zr0_6no3ah0 f10bgyvd"
data-fui-tree-item-value="root/child1Label"
data-test="TreeNodeContainer:root/child1Label"
role="treeitem"
style="height: 100%;"
tabindex="0"
@ -932,6 +942,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
treeNodeId="root/child2LoadingLabel"
>
<TreeItem
data-test="TreeNodeContainer:root/child2LoadingLabel"
itemType="branch"
onOpenChange={[Function]}
style={
@ -947,6 +958,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-selected="false"
className="fui-TreeItem r1hiwysc ___jer8zr0_6no3ah0 f10bgyvd"
data-fui-tree-item-value="root/child2LoadingLabel"
data-test="TreeNodeContainer:root/child2LoadingLabel"
onChange={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
@ -1046,6 +1058,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-selected="false"
class="fui-TreeItem r1hiwysc ___jer8zr0_6no3ah0 f10bgyvd"
data-fui-tree-item-value="root/child2LoadingLabel"
data-test="TreeNodeContainer:root/child2LoadingLabel"
role="treeitem"
style="height: 100%;"
tabindex="-1"
@ -1212,6 +1225,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
treeNodeId="root/child3ExpandingLabel"
>
<TreeItem
data-test="TreeNodeContainer:root/child3ExpandingLabel"
itemType="leaf"
onOpenChange={[Function]}
style={
@ -1226,6 +1240,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-selected="false"
className="fui-TreeItem r1hiwysc ___jer8zr0_6no3ah0 f10bgyvd"
data-fui-tree-item-value="root/child3ExpandingLabel"
data-test="TreeNodeContainer:root/child3ExpandingLabel"
onChange={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
@ -1332,6 +1347,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
aria-selected="false"
class="fui-TreeItem r1hiwysc ___jer8zr0_6no3ah0 f10bgyvd"
data-fui-tree-item-value="root/child3ExpandingLabel"
data-test="TreeNodeContainer:root/child3ExpandingLabel"
role="treeitem"
style="height: 100%;"
tabindex="-1"
@ -1455,6 +1471,7 @@ exports[`TreeNodeComponent fully renders a tree 1`] = `
exports[`TreeNodeComponent renders a loading spinner if the node is loading: loaded 1`] = `
<TreeItem
data-test="TreeNodeContainer:root"
itemType="leaf"
onOpenChange={[Function]}
style={
@ -1493,6 +1510,7 @@ exports[`TreeNodeComponent renders a loading spinner if the node is loading: loa
exports[`TreeNodeComponent renders a loading spinner if the node is loading: loading 1`] = `
<TreeItem
data-test="TreeNodeContainer:root"
itemType="leaf"
onOpenChange={[Function]}
style={
@ -1582,6 +1600,7 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
disableButtonEnhancement={true}
>
<TreeItem
data-test="TreeNodeContainer:root"
itemType="leaf"
onOpenChange={[Function]}
style={
@ -1675,6 +1694,7 @@ exports[`TreeNodeComponent renders a node with a menu 1`] = `
exports[`TreeNodeComponent renders a single node 1`] = `
<TreeItem
data-test="TreeNodeContainer:root"
itemType="leaf"
onOpenChange={[Function]}
style={
@ -1713,6 +1733,7 @@ exports[`TreeNodeComponent renders a single node 1`] = `
exports[`TreeNodeComponent renders an icon if the node has one 1`] = `
<TreeItem
data-test="TreeNodeContainer:root"
itemType="leaf"
onOpenChange={[Function]}
style={
@ -1751,6 +1772,7 @@ exports[`TreeNodeComponent renders an icon if the node has one 1`] = `
exports[`TreeNodeComponent renders selected parent node as selected if no descendant nodes are selected 1`] = `
<TreeItem
data-test="TreeNodeContainer:root"
itemType="branch"
onOpenChange={[Function]}
style={
@ -1839,6 +1861,7 @@ exports[`TreeNodeComponent renders selected parent node as selected if no descen
exports[`TreeNodeComponent renders selected parent node as unselected if any descendant node is selected 1`] = `
<TreeItem
data-test="TreeNodeContainer:root"
itemType="branch"
onOpenChange={[Function]}
style={
@ -1928,6 +1951,7 @@ exports[`TreeNodeComponent renders selected parent node as unselected if any des
exports[`TreeNodeComponent renders single selected leaf node as selected 1`] = `
<TreeItem
data-test="TreeNodeContainer:root"
itemType="leaf"
onOpenChange={[Function]}
style={

View File

@ -72,11 +72,11 @@ export const convertButton = (btns: CommandButtonComponentProps[], backgroundCol
: undefined,
key: `${btn.commandButtonLabel}${index}`,
text: label,
"data-test": label,
title: btn.tooltipText,
name: label,
disabled: btn.disabled,
ariaLabel: btn.ariaLabel,
"data-test": `CommandBar/Button:${label}`,
buttonStyles: {
root: {
backgroundColor: backgroundColor,

View File

@ -379,6 +379,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
>
<CustomizedPrimaryButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="OK"
@ -386,6 +387,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
>
<PrimaryButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="OK"
@ -666,6 +668,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
>
<CustomizedDefaultButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -948,6 +951,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
>
<DefaultButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -1231,6 +1235,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
<BaseButton
ariaLabel="OK"
baseClassName="ms-Button"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -2105,6 +2110,7 @@ exports[`Delete Collection Confirmation Pane submit() should call delete collect
aria-label="OK"
className="ms-Button ms-Button--primary root-122"
data-is-focusable={true}
data-test="Panel/OkButton"
id="sidePanelOkButton"
onClick={[Function]}
onKeyDown={[Function]}

View File

@ -9,6 +9,7 @@ import { DefaultExperienceUtility } from "Shared/DefaultExperienceUtility";
import { Action, ActionModifiers } from "Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor";
import { userContext } from "UserContext";
import { getDatabaseName } from "Utils/APITypeUtils";
import { logConsoleError } from "Utils/NotificationConsoleUtils";
import { useSidePanel } from "hooks/useSidePanel";
import { useTabs } from "hooks/useTabs";
@ -37,11 +38,11 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
const submit = async (): Promise<void> => {
if (selectedDatabase?.id() && databaseInput !== selectedDatabase.id()) {
setFormError(
`Input database name "${databaseInput}" does not match the selected database "${selectedDatabase.id()}"`,
`Input ${getDatabaseName()} name "${databaseInput}" does not match the selected ${getDatabaseName()} "${selectedDatabase.id()}"`,
);
logConsoleError(`Error while deleting collection ${selectedDatabase && selectedDatabase.id()}`);
logConsoleError(`Error while deleting ${getDatabaseName()} ${selectedDatabase && selectedDatabase.id()}`);
logConsoleError(
`Input database name "${databaseInput}" does not match the selected database "${selectedDatabase.id()}"`,
`Input ${getDatabaseName()} name "${databaseInput}" does not match the selected ${getDatabaseName()} "${selectedDatabase.id()}"`,
);
return;
}
@ -123,17 +124,18 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
message:
"Warning! The action you are about to take cannot be undone. Continuing will permanently delete this resource and all of its children resources.",
};
const confirmDatabase = "Confirm by typing the database id";
const reasonInfo = "Help us improve Azure Cosmos DB! What is the reason why you are deleting this database?";
const confirmDatabase = `Confirm by typing the ${getDatabaseName()} id`;
const reasonInfo = `Help us improve Azure Cosmos DB! What is the reason why you are deleting this ${getDatabaseName()}?`;
return (
<RightPaneForm {...props}>
{!formError && <PanelInfoErrorComponent {...errorProps} />}
<div className="panelMainContent">
<div className="confirmDeleteInput">
<span className="mandatoryStar">* </span>
<Text variant="small">Confirm by typing the database id</Text>
<Text variant="small">Confirm by typing the {getDatabaseName()} id</Text>
<TextField
id="confirmDatabaseId"
data-test="Input:confirmDatabaseId"
autoFocus
styles={{ fieldGroup: { width: 300 } }}
onChange={(event, newInput?: string) => {
@ -149,7 +151,7 @@ export const DeleteDatabaseConfirmationPanel: FunctionComponent<DeleteDatabaseCo
Help us improve Azure Cosmos DB!
</Text>
<Text variant="small" block>
What is the reason why you are deleting this database?
What is the reason why you are deleting this {getDatabaseName()}?
</Text>
<TextField
id="deleteDatabaseFeedbackInput"

View File

@ -5312,6 +5312,7 @@ exports[`Excute Sproc Param Pane should render Default properly 1`] = `
>
<CustomizedPrimaryButton
ariaLabel="Execute"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="Execute"
@ -5319,6 +5320,7 @@ exports[`Excute Sproc Param Pane should render Default properly 1`] = `
>
<PrimaryButton
ariaLabel="Execute"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="Execute"
@ -5599,6 +5601,7 @@ exports[`Excute Sproc Param Pane should render Default properly 1`] = `
>
<CustomizedDefaultButton
ariaLabel="Execute"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -5881,6 +5884,7 @@ exports[`Excute Sproc Param Pane should render Default properly 1`] = `
>
<DefaultButton
ariaLabel="Execute"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -6164,6 +6168,7 @@ exports[`Excute Sproc Param Pane should render Default properly 1`] = `
<BaseButton
ariaLabel="Execute"
baseClassName="ms-Button"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -7038,6 +7043,7 @@ exports[`Excute Sproc Param Pane should render Default properly 1`] = `
aria-label="Execute"
className="ms-Button ms-Button--primary root-148"
data-is-focusable={true}
data-test="Panel/OkButton"
id="sidePanelOkButton"
onClick={[Function]}
onKeyDown={[Function]}

View File

@ -16,6 +16,7 @@ export const PanelFooterComponent: React.FunctionComponent<PanelFooterProps> = (
<PrimaryButton
type="submit"
id="sidePanelOkButton"
data-test="Panel/OkButton"
text={buttonLabel}
ariaLabel={buttonLabel}
disabled={!!isButtonDisabled}

View File

@ -21,6 +21,7 @@ exports[`Right Pane Form should render Default properly 1`] = `
>
<CustomizedPrimaryButton
ariaLabel="Load"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="Load"
@ -28,6 +29,7 @@ exports[`Right Pane Form should render Default properly 1`] = `
>
<PrimaryButton
ariaLabel="Load"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="Load"
@ -308,6 +310,7 @@ exports[`Right Pane Form should render Default properly 1`] = `
>
<CustomizedDefaultButton
ariaLabel="Load"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -590,6 +593,7 @@ exports[`Right Pane Form should render Default properly 1`] = `
>
<DefaultButton
ariaLabel="Load"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -873,6 +877,7 @@ exports[`Right Pane Form should render Default properly 1`] = `
<BaseButton
ariaLabel="Load"
baseClassName="ms-Button"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -1747,6 +1752,7 @@ exports[`Right Pane Form should render Default properly 1`] = `
aria-label="Load"
className="ms-Button ms-Button--primary root-109"
data-is-focusable={true}
data-test="Panel/OkButton"
id="sidePanelOkButton"
onClick={[Function]}
onKeyDown={[Function]}

View File

@ -688,6 +688,7 @@ exports[`StringInput Pane should render Create new directory properly 1`] = `
>
<CustomizedPrimaryButton
ariaLabel="Create"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="Create"
@ -695,6 +696,7 @@ exports[`StringInput Pane should render Create new directory properly 1`] = `
>
<PrimaryButton
ariaLabel="Create"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="Create"
@ -975,6 +977,7 @@ exports[`StringInput Pane should render Create new directory properly 1`] = `
>
<CustomizedDefaultButton
ariaLabel="Create"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -1257,6 +1260,7 @@ exports[`StringInput Pane should render Create new directory properly 1`] = `
>
<DefaultButton
ariaLabel="Create"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -1540,6 +1544,7 @@ exports[`StringInput Pane should render Create new directory properly 1`] = `
<BaseButton
ariaLabel="Create"
baseClassName="ms-Button"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -2414,6 +2419,7 @@ exports[`StringInput Pane should render Create new directory properly 1`] = `
aria-label="Create"
className="ms-Button ms-Button--primary root-122"
data-is-focusable={true}
data-test="Panel/OkButton"
id="sidePanelOkButton"
onClick={[Function]}
onKeyDown={[Function]}

View File

@ -1258,6 +1258,7 @@ exports[`Table query select Panel should render Default properly 1`] = `
>
<CustomizedPrimaryButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="OK"
@ -1265,6 +1266,7 @@ exports[`Table query select Panel should render Default properly 1`] = `
>
<PrimaryButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="OK"
@ -1545,6 +1547,7 @@ exports[`Table query select Panel should render Default properly 1`] = `
>
<CustomizedDefaultButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -1827,6 +1830,7 @@ exports[`Table query select Panel should render Default properly 1`] = `
>
<DefaultButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -2110,6 +2114,7 @@ exports[`Table query select Panel should render Default properly 1`] = `
<BaseButton
ariaLabel="OK"
baseClassName="ms-Button"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -2984,6 +2989,7 @@ exports[`Table query select Panel should render Default properly 1`] = `
aria-label="OK"
className="ms-Button ms-Button--primary root-125"
data-is-focusable={true}
data-test="Panel/OkButton"
id="sidePanelOkButton"
onClick={[Function]}
onKeyDown={[Function]}

View File

@ -369,6 +369,7 @@ exports[`Excute Add Table Entity Pane should render Default properly 1`] = `
>
<CustomizedPrimaryButton
ariaLabel="Add Entity"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="Add Entity"
@ -376,6 +377,7 @@ exports[`Excute Add Table Entity Pane should render Default properly 1`] = `
>
<PrimaryButton
ariaLabel="Add Entity"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="Add Entity"
@ -656,6 +658,7 @@ exports[`Excute Add Table Entity Pane should render Default properly 1`] = `
>
<CustomizedDefaultButton
ariaLabel="Add Entity"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -938,6 +941,7 @@ exports[`Excute Add Table Entity Pane should render Default properly 1`] = `
>
<DefaultButton
ariaLabel="Add Entity"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -1221,6 +1225,7 @@ exports[`Excute Add Table Entity Pane should render Default properly 1`] = `
<BaseButton
ariaLabel="Add Entity"
baseClassName="ms-Button"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -2095,6 +2100,7 @@ exports[`Excute Add Table Entity Pane should render Default properly 1`] = `
aria-label="Add Entity"
className="ms-Button ms-Button--primary root-113"
data-is-focusable={true}
data-test="Panel/OkButton"
id="sidePanelOkButton"
onClick={[Function]}
onKeyDown={[Function]}

View File

@ -375,6 +375,7 @@ exports[`Excute Edit Table Entity Pane should render Default properly 1`] = `
>
<CustomizedPrimaryButton
ariaLabel="Update"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="Update"
@ -382,6 +383,7 @@ exports[`Excute Edit Table Entity Pane should render Default properly 1`] = `
>
<PrimaryButton
ariaLabel="Update"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="Update"
@ -662,6 +664,7 @@ exports[`Excute Edit Table Entity Pane should render Default properly 1`] = `
>
<CustomizedDefaultButton
ariaLabel="Update"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -944,6 +947,7 @@ exports[`Excute Edit Table Entity Pane should render Default properly 1`] = `
>
<DefaultButton
ariaLabel="Update"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -1227,6 +1231,7 @@ exports[`Excute Edit Table Entity Pane should render Default properly 1`] = `
<BaseButton
ariaLabel="Update"
baseClassName="ms-Button"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -2101,6 +2106,7 @@ exports[`Excute Edit Table Entity Pane should render Default properly 1`] = `
aria-label="Update"
className="ms-Button ms-Button--primary root-113"
data-is-focusable={true}
data-test="Panel/OkButton"
id="sidePanelOkButton"
onClick={[Function]}
onKeyDown={[Function]}

View File

@ -361,12 +361,15 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
<span
className="css-113"
>
Confirm by typing the database id
Confirm by typing the
Database
id
</span>
</Text>
<StyledTextFieldBase
ariaLabel="Confirm by typing the database id"
ariaLabel="Confirm by typing the Database id"
autoFocus={true}
data-test="Input:confirmDatabaseId"
id="confirmDatabaseId"
onChange={[Function]}
required={true}
@ -379,8 +382,9 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
}
>
<TextFieldBase
ariaLabel="Confirm by typing the database id"
ariaLabel="Confirm by typing the Database id"
autoFocus={true}
data-test="Input:confirmDatabaseId"
deferredValidationTime={200}
id="confirmDatabaseId"
onChange={[Function]}
@ -673,9 +677,10 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
>
<input
aria-invalid={false}
aria-label="Confirm by typing the database id"
aria-label="Confirm by typing the Database id"
autoFocus={true}
className="ms-TextField-field field-117"
data-test="Input:confirmDatabaseId"
id="confirmDatabaseId"
onBlur={[Function]}
onChange={[Function]}
@ -711,11 +716,13 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
<span
className="css-126"
>
What is the reason why you are deleting this database?
What is the reason why you are deleting this
Database
?
</span>
</Text>
<StyledTextFieldBase
ariaLabel="Help us improve Azure Cosmos DB! What is the reason why you are deleting this database?"
ariaLabel="Help us improve Azure Cosmos DB! What is the reason why you are deleting this Database?"
id="deleteDatabaseFeedbackInput"
multiline={true}
onChange={[Function]}
@ -729,7 +736,7 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
}
>
<TextFieldBase
ariaLabel="Help us improve Azure Cosmos DB! What is the reason why you are deleting this database?"
ariaLabel="Help us improve Azure Cosmos DB! What is the reason why you are deleting this Database?"
deferredValidationTime={200}
id="deleteDatabaseFeedbackInput"
multiline={true}
@ -1023,7 +1030,7 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
>
<textarea
aria-invalid={false}
aria-label="Help us improve Azure Cosmos DB! What is the reason why you are deleting this database?"
aria-label="Help us improve Azure Cosmos DB! What is the reason why you are deleting this Database?"
className="ms-TextField-field field-128"
id="deleteDatabaseFeedbackInput"
onBlur={[Function]}
@ -1049,6 +1056,7 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
>
<CustomizedPrimaryButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="OK"
@ -1056,6 +1064,7 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
>
<PrimaryButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
text="OK"
@ -1336,6 +1345,7 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
>
<CustomizedDefaultButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -1618,6 +1628,7 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
>
<DefaultButton
ariaLabel="OK"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -1901,6 +1912,7 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
<BaseButton
ariaLabel="OK"
baseClassName="ms-Button"
data-test="Panel/OkButton"
disabled={false}
id="sidePanelOkButton"
onRenderDescription={[Function]}
@ -2775,6 +2787,7 @@ exports[`Delete Database Confirmation Pane Should call delete database 1`] = `
aria-label="OK"
className="ms-Button ms-Button--primary root-130"
data-is-focusable={true}
data-test="Panel/OkButton"
id="sidePanelOkButton"
onClick={[Function]}
onKeyDown={[Function]}

View File

@ -128,6 +128,7 @@ const App: React.FunctionComponent = () => {
// Setting key is needed so React will re-render this element on any account change
key={databaseAccount?.id || encryptedTokenMetadata?.accountName || authType}
ref={ref}
data-test="DataExplorerFrame"
id="explorerMenu"
name="explorer"
className="iframe"

View File

@ -93,7 +93,7 @@ const App: React.FunctionComponent = () => {
return (
<KeyboardShortcutRoot>
<div className="flexContainer" aria-hidden="false">
<div className="flexContainer" aria-hidden="false" data-test="DataExplorerRoot">
<div id="divExplorer" className="flexContainer hideOverflows">
<div id="freeTierTeachingBubble"> </div>
{/* Main Command Bar - Start */}

View File

@ -133,7 +133,7 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
<div id="connectWithAad">
<input className="filterbtnstyle" type="button" value="Sign In" onClick={login} />
{enableConnectionStringLogin && (
<p className="switchConnectTypeText" onClick={showForm}>
<p className="switchConnectTypeText" data-test="Link:SwitchConnectionType" onClick={showForm}>
Connect to your account with connection string
</p>
)}

View File

@ -463,7 +463,7 @@ export class SelfServeComponent extends React.Component<SelfServeComponentProps,
);
}
return (
<div style={{ overflowX: "auto" }}>
<div style={{ overflowX: "auto" }} data-test="DataExplorerRoot">
<Stack tokens={containerStackTokens}>
<Stack.Item>
<CommandBar styles={commandBarStyles} items={this.getCommandBarItems()} />

View File

@ -2,6 +2,7 @@
exports[`SelfServeComponent message bar and spinner snapshots 1`] = `
<div
data-test="DataExplorerRoot"
style={
Object {
"overflowX": "auto",
@ -338,6 +339,7 @@ exports[`SelfServeComponent message bar and spinner snapshots 1`] = `
exports[`SelfServeComponent message bar and spinner snapshots 2`] = `
<div
data-test="DataExplorerRoot"
style={
Object {
"overflowX": "auto",
@ -732,6 +734,7 @@ exports[`SelfServeComponent message bar and spinner snapshots 2`] = `
exports[`SelfServeComponent message bar and spinner snapshots 3`] = `
<div
data-test="DataExplorerRoot"
style={
Object {
"overflowX": "auto",
@ -832,6 +835,7 @@ exports[`SelfServeComponent message bar and spinner snapshots 4`] = `
exports[`SelfServeComponent should render and honor save, discard, refresh actions 1`] = `
<div
data-test="DataExplorerRoot"
style={
Object {
"overflowX": "auto",

143
test/README.md Normal file
View File

@ -0,0 +1,143 @@
# End-to-End Test Suite
This directory contains end-to-end tests for Cosmos Data Explorer.
These tests **require** that you either deploy, or have access to, several Cosmos test Accounts.
The tests run in [Playwright](https://playwright.dev/), using the official Playwright test framework.
## Required Resources
To run all the tests, you need:
* A CosmosDB Account using the Cassandra API
* A CosmosDB Account using the Gremlin API
* A CosmosDB Account using the MongoDB API, API version 6.0
* A CosmosDB Account using the MongoDB API, API version 3.2
* A CosmosDB Account using the NoSQL API
* A CosmosDB Account using the Tables API
Each Account must have at least 1000 RU/s of throughput available for new databases/collections/etc.
The tests create new databases/keyspaces/etc. for each test, and delete them when the test is done.
So it should be safe to use these accounts for other testing purposes, as long as you make sure to have enough throughput available when running the tests.
You can specify the resource to use using the Environment Variables configuration below.
Or, you can deploy resources specifically for testing using the `deploy` script.
### Using the deploy script
> [!NOTE]
> This script currently only works on Windows.
The `resources` directory contains a `deploy.ps1` script that will deploy the required resources for testing.
They use a Bicep template to deploy the resources.
All you need to provide is a resource group to deploy in to.
To use this script, there are a few prerequisites that must be done at least once:
1. [Install Azure PowerShell](https://learn.microsoft.com/en-us/powershell/azure/install-azps-windows?view=azps-12.0.0&tabs=powershell&pivots=windows-psgallery) if you don't already have it.
2. Connect to your Azure account using `Connect-AzAccount`.
3. Ensure you have a Resource Group _ready_ to deploy into, the deploy script requires an existing resource group. This resource group should be named `[username]-e2e-testing`, where `[username]` is your Windows username, (**Microsoft employees:** This should be your alias). The easiest way to do this is by running the `create-resource-group.ps1` script, specifying the Subscription (Name or ID) and Location in which you want to create the Resource Group. For example:
```powershell
.\test\resources\create-resource-group.ps1 -SubscriptionName "My Subscription" -Location "West US 3"
```
Then, whenever you want to create/update the resources, you can run the `deploy.ps1` script in the `resources` directory. As long as you're using the default naming convention (`[username]-e2e-testing`), you just need to specify the Subscription. For example:
```powershell
.\test\resources\deploy.ps1 -SubscriptionName "My Subscription"
```
You'll get a confirmation prompt before anything is deployed:
```
Found a resource group with the default name (ashleyst-e2e-testing). Using that resource group. If you want to use a different resource group, specify it as a parameter.
Deploying test resource sets: tables cassandra gremlin mongo mongo32 sql
in West US 3
to resource group ashleyst-e2e-testing
in subscription ... (...)
Do you want to continue? (y/n):
```
This prompt shows:
* The resources that will be deployed, in this case, all of them. You can filter to deploy only a subset by specifying the `-ResourceTypes` parameter. For example `-ResourceTypes @("cassandra", "sql")`.
* The location the resources will be deployed to, `West US 3` in this case.
* The resource group that will be used, `ashleyst-e2e-testing` in this case.
* The subscription that will be used.
Once you confirm, the resources will be deployed using Azure PowerShell and the Bicep templates in the `resources` directory. The script will wait for all the deployments to complete before exiting.
You can re-run this script at any time to update the resources, if the Bicep templates have changed.
## Preparing the test environment
Before running the tests, you need to configure your environment to specify the accounts to use for testing.
The following environment variables are used:
* `DE_TEST_RESOURCE_GROUP` - The resource group to use for testing. This should be the same resource group that the resources were deployed to.
* `DE_TEST_SUBSCRIPTION_ID` - The subscription ID to use for testing. This should be the same subscription that the resources were deployed to.
* `DE_TEST_ACCOUNT_PREFIX` - If you used the default naming scheme provided by the `deploy.ps1` script, this should be your Windows username (or whatever value you passed in for the `-ResourcePrefix` argument when deploying). This is used to find the accounts that were deployed.
In the event you didn't use the `deploy.ps1` script, you can specify the accounts directly using the following environment variables:
* `DE_TEST_ACCOUNT_NAME_CASSANDRA` - The name of the CosmosDB Account using the Cassandra API.
* `DE_TEST_ACCOUNT_NAME_GREMLIN` - The name of the CosmosDB Account using the Gremlin API.
* `DE_TEST_ACCOUNT_NAME_MONGO` - The name of the CosmosDB Account using the MongoDB API, API version 6.0.
* `DE_TEST_ACCOUNT_NAME_MONGO32` - The name of the CosmosDB Account using the MongoDB API, API version 3.2.
* `DE_TEST_ACCOUNT_NAME_SQL` - The name of the CosmosDB Account using the NoSQL API.
* `DE_TEST_ACCOUNT_NAME_TABLES` - The name of the CosmosDB Account using the Tables API.
If you used all the standard deployment scripts and naming scheme, you can set these environment variables using the following command:
```powershell
.\test\scripts\set-test-accounts.ps1
```
If Azure Powershell's current subscription is not the one you want to use for testing, you can set the subscription using the following command:
```powershell
.\test\scripts\set-test-subscription.ps1 -Subscription "My Subscription"
```
That script will confirm the resource group exists and then set the necessary environment variables:
```
The currently-selected subscription is ... (...)
Do you want to use this subscription? (y/n): y
Found a resource with the default resource prefix (ashleyst-e2e-). Configuring that prefix for E2E testing.
Configuring for E2E Testing
Subscription: ... (...)
Resource Group: ashleyst-e2e-testing
Resource Prefix: ashleyst-e2e-
Found CosmosDB Account: ashleyst-e2e-cassandra
Found CosmosDB Account: ashleyst-e2e-gremlin
Found CosmosDB Account: ashleyst-e2e-mongo
Found CosmosDB Account: ashleyst-e2e-mongo32
Found CosmosDB Account: ashleyst-e2e-sql
Found CosmosDB Account: ashleyst-e2e-tables
```
## Running the tests
To run all tests in a headless browser, run the following command from the root of the repo:
```powershell
npx playwright test
```
> [!NOTE]
> You may be prompted to install the Playwright browsers the first time you run the tests.
The tests will use your existing server if you have one running, or start a new server if you don't.
When running individual tests, you may find it most useful to use the Playwright UI to run the tests.
You can do this by running the following command:
```powershell
npx playwright test --ui
```
The UI allows you to select a specific test to run and to see the results of the test in the browser.
See the [Playwright docs](https://playwright.dev/docs/running-tests) for more information on running tests.

View File

@ -1,57 +1,39 @@
import { jest } from "@jest/globals";
import "expect-playwright";
import {
AccountType,
generateUniqueName,
getPanelSelector,
getTestExplorerUrl,
getTreeMenuItemSelector,
getTreeNodeSelector,
openContextMenu,
} from "../utils/shared";
import { waitForExplorer } from "../utils/waitForExplorer";
jest.setTimeout(120000);
import { expect, test } from "@playwright/test";
test("Cassandra keyspace and table CRUD", async () => {
const keyspaceId = generateUniqueName("keyspace");
import { DataExplorer, TestAccount, generateDatabaseNameWithTimestamp, generateUniqueName } from "../fx";
test("Cassandra keyspace and table CRUD", async ({ page }) => {
const keyspaceId = generateDatabaseNameWithTimestamp();
const tableId = generateUniqueName("table");
page.setDefaultTimeout(50000);
const explorer = await DataExplorer.open(page, TestAccount.Cassandra);
const url = await getTestExplorerUrl(AccountType.Cassandra);
await page.goto(url);
await page.waitForSelector("iframe");
const explorer = await waitForExplorer();
await explorer.commandBarButton("New Table").click();
await explorer.whilePanelOpen("Add Table", async (panel, okButton) => {
await panel.getByPlaceholder("Type a new keyspace id").fill(keyspaceId);
await panel.getByPlaceholder("Enter table Id").fill(tableId);
await panel.getByLabel("Table max RU/s").fill("1000");
await okButton.click();
});
await explorer.click('[data-test="New Table"]');
const keyspaceNode = explorer.treeNode(`DATA/${keyspaceId}`);
await keyspaceNode.expand();
const tableNode = explorer.treeNode(`DATA/${keyspaceId}/${tableId}`);
await explorer.waitForSelector(getPanelSelector("Add Table"));
await explorer.click('[aria-label="Keyspace id"]');
await explorer.fill('[aria-label="Keyspace id"]', keyspaceId);
await explorer.click('[aria-label="addCollection-table Id Create table"]');
await explorer.fill('[aria-label="addCollection-table Id Create table"]', tableId);
await explorer.fill('[aria-label="Table max RU/s"]', "1000");
await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("Add Table"), { state: "detached" });
await tableNode.openContextMenu();
await tableNode.contextMenuItem("Delete Table").click();
await explorer.whilePanelOpen("Delete Table", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the table id" }).fill(tableId);
await okButton.click();
});
await expect(tableNode.element).not.toBeAttached();
await explorer.click(getTreeNodeSelector(`DATA/${keyspaceId}`));
await openContextMenu(explorer, `DATA/${keyspaceId}/${tableId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${keyspaceId}/${tableId}`, "Delete Table"));
await keyspaceNode.openContextMenu();
await keyspaceNode.contextMenuItem("Delete Keyspace").click();
await explorer.whilePanelOpen("Delete Keyspace", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the Keyspace id" }).fill(keyspaceId);
await okButton.click();
});
await explorer.waitForSelector(getPanelSelector("Delete Table"));
await explorer.fill('text=* Confirm by typing the table id >> input[type="text"]', tableId);
await explorer.click('[aria-label="OK"]');
await explorer.waitForSelector(getPanelSelector("Delete Table"), { state: "detached" });
await openContextMenu(explorer, `DATA/${keyspaceId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${keyspaceId}`, "Delete Keyspace"));
await explorer.waitForSelector(getPanelSelector("Delete Keyspace"));
await explorer.click('text=* Confirm by typing the database id >> input[type="text"]');
await explorer.fill('text=* Confirm by typing the database id >> input[type="text"]', keyspaceId);
await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("Delete Keyspace"), { state: "detached" });
await expect(explorer).not.toHaveText(".dataResourceTree", keyspaceId);
await expect(explorer).not.toHaveText(".dataResourceTree", tableId);
await expect(keyspaceNode.element).not.toBeAttached();
});

151
test/fx.ts Normal file
View File

@ -0,0 +1,151 @@
import { AzureCliCredentials } from "@azure/ms-rest-nodeauth";
import { expect, Frame, Locator, Page } from "@playwright/test";
import crypto from "crypto";
export function generateUniqueName(baseName = "", length = 4): string {
return `${baseName}${crypto.randomBytes(length).toString("hex")}`;
}
export function generateDatabaseNameWithTimestamp(baseName = "db", length = 1): string {
// We use '_' as the separator because it's supported across all the API types.
return `${baseName}${crypto.randomBytes(length).toString("hex")}_${Date.now()}`;
}
export async function getAzureCLICredentials(): Promise<AzureCliCredentials> {
return await AzureCliCredentials.create();
}
export async function getAzureCLICredentialsToken(): Promise<string> {
const credentials = await getAzureCLICredentials();
const token = (await credentials.getToken()).accessToken;
return token;
}
export enum TestAccount {
Tables = "Tables",
Cassandra = "Cassandra",
Gremlin = "Gremlin",
Mongo = "Mongo",
Mongo32 = "Mongo32",
SQL = "SQL",
}
export const defaultAccounts: Record<TestAccount, string> = {
[TestAccount.Tables]: "portal-tables-runner",
[TestAccount.Cassandra]: "portal-cassandra-runner",
[TestAccount.Gremlin]: "portal-gremlin-runner",
[TestAccount.Mongo]: "portal-mongo-runner",
[TestAccount.Mongo32]: "portal-mongo32-runner",
[TestAccount.SQL]: "portal-sql-runner-west-us",
};
export const resourceGroupName = process.env.DE_TEST_RESOURCE_GROUP ?? "runners";
export const subscriptionId = process.env.DE_TEST_SUBSCRIPTION_ID ?? "69e02f2d-f059-4409-9eac-97e8a276ae2c";
function tryGetStandardName(accountType: TestAccount) {
if (process.env.DE_TEST_ACCOUNT_PREFIX) {
const actualPrefix = process.env.DE_TEST_ACCOUNT_PREFIX.endsWith("-")
? process.env.DE_TEST_ACCOUNT_PREFIX
: `${process.env.DE_TEST_ACCOUNT_PREFIX}-`;
return `${actualPrefix}${accountType.toLocaleLowerCase()}`;
}
}
export function getAccountName(accountType: TestAccount) {
return (
process.env[`DE_TEST_ACCOUNT_NAME_${accountType.toLocaleUpperCase()}`] ??
tryGetStandardName(accountType) ??
defaultAccounts[accountType]
);
}
export async function getTestExplorerUrl(accountType: TestAccount, iframeSrc?: string): Promise<string> {
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
const accountName = getAccountName(accountType);
const baseUrl = `https://localhost:1234/testExplorer.html?accountName=${accountName}&resourceGroup=${resourceGroupName}&subscriptionId=${subscriptionId}&token=${token}`;
if (iframeSrc) {
return `${baseUrl}&iframeSrc=${iframeSrc}`;
}
return baseUrl;
}
/** Helper class that provides locator methods for TreeNode elements, on top of a Locator */
class TreeNode {
constructor(
public element: Locator,
public frame: Frame,
public id: string,
) {}
async openContextMenu(): Promise<void> {
await this.element.click({ button: "right" });
}
contextMenuItem(name: string): Locator {
return this.frame.getByTestId(`TreeNode/ContextMenuItem:${name}`);
}
async expand(): Promise<void> {
// Sometimes, the expand button doesn't load at all, because the node didn't have children when it was initially loaded.
// Still, clicking the node will trigger loading and expansion. So if the node isn't expanded, we click it.
// The "aria-expanded" attribute is applied to the TreeItem. But we have the TreeItemLayout selected because the TreeItem contains the child tree as well.
// So, we need to find the TreeItem that contains this TreeItemLayout.
const treeNodeContainer = this.frame.getByTestId(`TreeNodeContainer:${this.id}`);
if ((await treeNodeContainer.getAttribute("aria-expanded")) !== "true") {
// Click the node, to trigger loading and expansion
await this.element.click();
}
await expect(treeNodeContainer).toHaveAttribute("aria-expanded", "true");
}
}
/** Helper class that provides locator methods for DataExplorer components, on top of a Frame */
export class DataExplorer {
constructor(public frame: Frame) {}
commandBarButton(label: string): Locator {
return this.frame.getByTestId(`CommandBar/Button:${label}`).and(this.frame.locator("css=button"));
}
panel(title: string): Locator {
return this.frame.getByTestId(`Panel:${title}`);
}
treeNode(id: string): TreeNode {
return new TreeNode(this.frame.getByTestId(`TreeNode:${id}`), this.frame, id);
}
async whilePanelOpen(title: string, action: (panel: Locator, okButton: Locator) => Promise<void>): Promise<void> {
const panel = this.panel(title);
await panel.waitFor();
const okButton = panel.getByTestId("Panel/OkButton");
await action(panel, okButton);
await panel.waitFor({ state: "detached" });
}
static async waitForExplorer(page: Page) {
const iframeElement = await page.getByTestId("DataExplorerFrame").elementHandle();
if (iframeElement === null) {
throw new Error("Explorer iframe not found");
}
const explorerFrame = await iframeElement.contentFrame();
if (explorerFrame === null) {
throw new Error("Explorer frame not found");
}
await explorerFrame?.getByTestId("DataExplorerRoot").waitFor();
return new DataExplorer(explorerFrame);
}
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<DataExplorer> {
const url = await getTestExplorerUrl(testAccount, iframeSrc);
await page.goto(url);
return DataExplorer.waitForExplorer(page);
}
}

View File

@ -1,58 +0,0 @@
import { jest } from "@jest/globals";
import "expect-playwright";
import {
generateDatabaseNameWithTimestamp,
generateUniqueName,
getAzureCLICredentialsToken,
getPanelSelector,
getTreeMenuItemSelector,
getTreeNodeSelector,
openContextMenu,
} from "../utils/shared";
import { waitForExplorer } from "../utils/waitForExplorer";
jest.setTimeout(240000);
test("Graph CRUD", async () => {
const databaseId = generateDatabaseNameWithTimestamp();
const containerId = generateUniqueName("container");
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
page.setDefaultTimeout(50000);
await page.goto(`https://localhost:1234/testExplorer.html?accountName=portal-gremlin-runner&token=${token}`);
const explorer = await waitForExplorer();
// Create new database and graph
await explorer.click('[data-test="New Graph"]');
await explorer.waitForSelector(getPanelSelector("New Graph"));
await explorer.fill('[aria-label="New database id, Type a new database id"]', databaseId);
await explorer.fill('[aria-label="Graph id, Example Graph1"]', containerId);
await explorer.fill('[aria-label="Partition key"]', "/pk");
await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("New Graph"), { state: "detached" });
// Delete database and graph
await explorer.click(getTreeNodeSelector(`DATA/${databaseId}`));
await openContextMenu(explorer, `DATA/${databaseId}/${containerId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${databaseId}/${containerId}`, "Delete Graph"));
await explorer.waitForSelector(getPanelSelector("Delete Graph"));
await explorer.fill('text=* Confirm by typing the graph id >> input[type="text"]', containerId);
await explorer.click('[aria-label="OK"]');
await explorer.waitForSelector(getPanelSelector("Delete Graph"), { state: "detached" });
await openContextMenu(explorer, `DATA/${databaseId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${databaseId}`, "Delete Database"));
await explorer.waitForSelector(getPanelSelector("Delete Database"));
await explorer.click('text=* Confirm by typing the database id >> input[type="text"]');
await explorer.fill('text=* Confirm by typing the database id >> input[type="text"]', databaseId);
await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("Delete Database"), { state: "detached" });
await expect(explorer).not.toHaveText(".dataResourceTree", databaseId);
await expect(explorer).not.toHaveText(".dataResourceTree", containerId);
});

View File

@ -0,0 +1,41 @@
import { expect, test } from "@playwright/test";
import { DataExplorer, TestAccount, generateDatabaseNameWithTimestamp, generateUniqueName } from "../fx";
test("Gremlin graph CRUD", async ({ page }) => {
const databaseId = generateDatabaseNameWithTimestamp();
const graphId = generateUniqueName("graph");
const explorer = await DataExplorer.open(page, TestAccount.Gremlin);
// Create new database and graph
await explorer.commandBarButton("New Graph").click();
await explorer.whilePanelOpen("New Graph", async (panel, okButton) => {
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
await panel.getByRole("textbox", { name: "Graph id, Example Graph1" }).fill(graphId);
await panel.getByRole("textbox", { name: "Partition key" }).fill("/pk");
await panel.getByLabel("Database max RU/s").fill("1000");
await okButton.click();
});
const databaseNode = explorer.treeNode(`DATA/${databaseId}`);
await databaseNode.expand();
const graphNode = explorer.treeNode(`DATA/${databaseId}/${graphId}`);
await graphNode.openContextMenu();
await graphNode.contextMenuItem("Delete Graph").click();
await explorer.whilePanelOpen("Delete Graph", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the graph id" }).fill(graphId);
await okButton.click();
});
await expect(graphNode.element).not.toBeAttached();
await databaseNode.openContextMenu();
await databaseNode.contextMenuItem("Delete Database").click();
await explorer.whilePanelOpen("Delete Database", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the Database id" }).fill(databaseId);
await okButton.click();
});
await expect(databaseNode.element).not.toBeAttached();
});

View File

@ -1,72 +1,47 @@
import { jest } from "@jest/globals";
import "expect-playwright";
import {
AccountType,
generateDatabaseNameWithTimestamp,
generateUniqueName,
getPanelSelector,
getTestExplorerUrl,
getTreeMenuItemSelector,
getTreeNodeSelector,
openContextMenu,
} from "../utils/shared";
import { waitForExplorer } from "../utils/waitForExplorer";
jest.setTimeout(240000);
import { expect, test } from "@playwright/test";
test("Mongo CRUD", async () => {
import { DataExplorer, TestAccount, generateDatabaseNameWithTimestamp, generateUniqueName } from "../fx";
(
[
["latest API version", TestAccount.Mongo],
["3.2 API", TestAccount.Mongo32],
] as [string, TestAccount][]
).forEach(([apiVersionDescription, accountType]) => {
test(`Mongo CRUD using ${apiVersionDescription}`, async ({ page }) => {
const databaseId = generateDatabaseNameWithTimestamp();
const containerId = generateUniqueName("container");
const collectionId = generateUniqueName("collection");
page.setDefaultTimeout(50000);
const explorer = await DataExplorer.open(page, accountType);
const url = await getTestExplorerUrl(AccountType.Mongo);
await page.goto(url);
const explorer = await waitForExplorer();
await explorer.commandBarButton("New Collection").click();
await explorer.whilePanelOpen("New Collection", async (panel, okButton) => {
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
await panel.getByRole("textbox", { name: "Collection id, Example Collection1" }).fill(collectionId);
await panel.getByRole("textbox", { name: "Shard key" }).fill("pk");
await panel.getByLabel("Database max RU/s").fill("1000");
await okButton.click();
});
// Create new database and collection
await explorer.click('[data-test="New Collection"]');
const databaseNode = explorer.treeNode(`DATA/${databaseId}`);
await databaseNode.expand();
const collectionNode = explorer.treeNode(`DATA/${databaseId}/${collectionId}`);
await explorer.waitForSelector(getPanelSelector("New Collection"));
await explorer.fill('[aria-label="New database id, Type a new database id"]', databaseId);
await explorer.fill('[aria-label="Collection id, Example Collection1"]', containerId);
await explorer.fill('[aria-label="Shard key"]', "pk");
await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("New Collection"), { state: "detached" });
await collectionNode.openContextMenu();
await collectionNode.contextMenuItem("Delete Collection").click();
await explorer.whilePanelOpen("Delete Collection", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the collection id" }).fill(collectionId);
await okButton.click();
});
await expect(collectionNode.element).not.toBeAttached();
await explorer.click(getTreeNodeSelector(`DATA/${databaseId}`));
await explorer.click(getTreeNodeSelector(`DATA/${databaseId}/${containerId}`));
await databaseNode.openContextMenu();
await databaseNode.contextMenuItem("Delete Database").click();
await explorer.whilePanelOpen("Delete Database", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the Database id" }).fill(databaseId);
await okButton.click();
});
// Create indexing policy
await explorer.click(getTreeNodeSelector(`DATA/${databaseId}/${containerId}/Settings`));
await explorer.click('button[role="tab"]:has-text("Indexing Policy")');
await explorer.click('[aria-label="Index Field Name 0"]');
await explorer.fill('[aria-label="Index Field Name 0"]', "foo");
await explorer.click("text=Select an index type");
await explorer.click('button[role="option"]:has-text("Single Field")');
await explorer.click('[data-test="Save"]');
// Remove indexing policy
await explorer.click('[aria-label="Delete index Button"]');
await explorer.click('[data-test="Save"]');
// Delete database and collection
await openContextMenu(explorer, `DATA/${databaseId}/${containerId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${databaseId}/${containerId}`, "Delete Collection"));
await explorer.waitForSelector(getPanelSelector("Delete Collection"));
await explorer.fill('text=* Confirm by typing the collection id >> input[type="text"]', containerId);
await explorer.click('[aria-label="OK"]');
await explorer.waitForSelector(getPanelSelector("Delete Collection"), { state: "detached" });
await openContextMenu(explorer, `DATA/${databaseId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${databaseId}`, "Delete Database"));
await explorer.waitForSelector(getPanelSelector("Delete Database"));
await explorer.click('text=* Confirm by typing the database id >> input[type="text"]');
await explorer.fill('text=* Confirm by typing the database id >> input[type="text"]', databaseId);
await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("Delete Database"), { state: "detached" });
await expect(explorer).not.toHaveText(".dataResourceTree", databaseId);
await expect(explorer).not.toHaveText(".dataResourceTree", containerId);
await expect(databaseNode.element).not.toBeAttached();
});
});

View File

@ -1,59 +0,0 @@
import { jest } from "@jest/globals";
import "expect-playwright";
import {
AccountType,
generateDatabaseNameWithTimestamp,
generateUniqueName,
getPanelSelector,
getTestExplorerUrl,
getTreeMenuItemSelector,
getTreeNodeSelector,
openContextMenu,
} from "../utils/shared";
import { waitForExplorer } from "../utils/waitForExplorer";
jest.setTimeout(240000);
test("Mongo CRUD", async () => {
const databaseId = generateDatabaseNameWithTimestamp();
const containerId = generateUniqueName("container");
page.setDefaultTimeout(50000);
const url = await getTestExplorerUrl(AccountType.Mongo32);
await page.goto(url);
const explorer = await waitForExplorer();
// Create new database and collection
await explorer.click('[data-test="New Collection"]');
await explorer.waitForSelector(getPanelSelector("New Collection"));
await explorer.fill('[aria-label="New database id, Type a new database id"]', databaseId);
await explorer.fill('[aria-label="Collection id, Example Collection1"]', containerId);
await explorer.fill('[aria-label="Shard key"]', "pk");
await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("New Collection"), { state: "detached" });
await explorer.click(getTreeNodeSelector(`DATA/${databaseId}`));
await explorer.click(getTreeNodeSelector(`DATA/${databaseId}/${containerId}`));
// Delete database and collection
await openContextMenu(explorer, `DATA/${databaseId}/${containerId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${databaseId}/${containerId}`, "Delete Collection"));
await explorer.waitForSelector(getPanelSelector("Delete Collection"));
await explorer.fill('text=* Confirm by typing the collection id >> input[type="text"]', containerId);
await explorer.click('[aria-label="OK"]');
await explorer.waitForSelector(getPanelSelector("Delete Collection"), { state: "detached" });
await openContextMenu(explorer, `DATA/${databaseId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${databaseId}`, "Delete Database"));
await explorer.waitForSelector(getPanelSelector("Delete Database"));
await explorer.click('text=* Confirm by typing the database id >> input[type="text"]');
await explorer.fill('text=* Confirm by typing the database id >> input[type="text"]', databaseId);
await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("Delete Database"), { state: "detached" });
await expect(explorer).not.toHaveText(".dataResourceTree", databaseId);
await expect(explorer).not.toHaveText(".dataResourceTree", containerId);
});

View File

@ -1,110 +0,0 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": "# Getting started with Cosmos notebooks\nIn this notebook, we'll learn how to use Cosmos notebook features. We'll create a database and container, import some sample data in a container in Azure Cosmos DB and run some queries over it."
},
{
"cell_type": "markdown",
"metadata": {},
"source": "### Create new database and container\n\nTo connect to the service, you can use our built-in instance of ```cosmos_client```. This is a ready to use instance of [CosmosClient](https://docs.microsoft.com/python/api/azure-cosmos/azure.cosmos.cosmos_client.cosmosclient?view=azure-python) from our Python SDK. It already has the context of this account baked in. We'll use ```cosmos_client``` to create a new database called **RetailDemo** and container called **WebsiteData**.\n\nOur dataset will contain events that occurred on the website - e.g. a user viewing an item, adding it to their cart, or purchasing it. We will partition by CartId, which represents the individual cart of each user. This will give us an even distribution of throughput and storage in our container. Learn more about how to [choose a good partition key.](https://docs.microsoft.com/azure/cosmos-db/partition-data)"
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"trusted": false
},
"outputs": [],
"source": "import azure.cosmos\nfrom azure.cosmos.partition_key import PartitionKey\n\ndatabase = cosmos_client.create_database_if_not_exists('RetailDemo')\nprint('Database RetailDemo created')\n\ncontainer = database.create_container_if_not_exists(id='WebsiteData', partition_key=PartitionKey(path='/CartID'))\nprint('Container WebsiteData created')\n"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "#### Set the default database and container context to the new resources\n\nWe can use the ```%database {database_id}``` and ```%container {container_id}``` syntax."
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"trusted": false
},
"outputs": [],
"source": "%database RetailDemo"
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"trusted": false
},
"outputs": [],
"source": "%container WebsiteData"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "### Load in sample JSON data and insert into the container. \nWe'll use the **%%upload** magic function to insert items into the container"
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"collapsed": false,
"inputHidden": false,
"outputHidden": false,
"trusted": false
},
"outputs": [],
"source": "%%upload --databaseName RetailDemo --containerName WebsiteData --url https://cosmosnotebooksdata.blob.core.windows.net/notebookdata/websiteData-small.json"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "The new database and container should show up under the **Data** section. Use the refresh icon after completing the previous cell. \n\n<img src=\"https://cosmosnotebooksdata.blob.core.windows.net/notebookdata/refreshData.png\" alt=\"Refresh Data resource tree to see newly created resources\" width=\"40%\"/>"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "### Run a query using the built-in Azure Cosmos notebook magic\n"
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"trusted": false
},
"outputs": [],
"source": "%%sql\nSELECT c.Action, c.Price as ItemRevenue, c.Country, c.Item FROM c"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "We can get more information about the %%sql command using ```%%sql?```"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "### Next steps\n\nNow that you've learned how to use basic notebook functionality, follow the **Visualization.ipynb** notebook to further analyze and visualize our data. You can find it under the **Sample Notebooks** section."
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"version": "3.6.8"
},
"nteract": {
"version": "dataExplorer 1.0"
}
},
"nbformat": 4,
"nbformat_minor": 2
}

View File

@ -1,25 +0,0 @@
import { jest } from "@jest/globals";
import "expect-playwright";
import fs from "fs";
import path from "path";
import { waitForExplorer } from "../utils/waitForExplorer";
jest.setTimeout(240000);
const filename = "GettingStarted.ipynb";
const fileToUpload = `GettingStarted-ignore${Math.floor(Math.random() * 100000)}.ipynb`;
fs.copyFileSync(path.join(__dirname, filename), path.join(__dirname, fileToUpload));
test("Notebooks", async () => {
await page.goto("https://localhost:1234/testExplorer.html?accountName=portal-sql-runner-west-us");
const explorer = await waitForExplorer();
// Upload and Delete Notebook
await explorer.click('[data-test="My Notebooks"] [aria-label="More"]');
await explorer.click('button[role="menuitem"]:has-text("Upload File")');
await explorer.setInputFiles("#importFileInput", path.join(__dirname, fileToUpload));
await explorer.click('[aria-label="Upload"]');
await explorer.click(`[data-test="${fileToUpload}"] [aria-label="More"]`);
await explorer.click('button[role="menuitem"]:has-text("Delete")');
await explorer.click('button:has-text("Delete")');
await expect(explorer).not.toHaveText(".notebookResourceTree", fileToUpload);
});

View File

@ -1,26 +0,0 @@
const PlaywrightEnvironment = require("jest-playwright-preset/lib/PlaywrightEnvironment").default;
class CustomEnvironment extends PlaywrightEnvironment {
async setup() {
await super.setup();
// Your setup
}
async teardown() {
// Your teardown
await super.teardown();
}
async handleTestEvent(event) {
if (event.name === "test_done" && event.test.errors.length > 0) {
const parentName = event.test.parent.name.replace(/\W/g, "-");
const specName = event.test.name.replace(/\W/g, "-");
await this.global.page.screenshot({
path: `screenshots/${parentName}_${specName}.png`,
});
}
}
}
module.exports = CustomEnvironment;

4
test/resources/README.md Normal file
View File

@ -0,0 +1,4 @@
# Azure Resources for E2E Testing
This directory contains Bicep files for creating the necessary Azure resources for end-to-end testing.
To deploy the resources, run the './deploy.ps1' script in this directory and follow the prompts.

View File

@ -0,0 +1,50 @@
targetScope = 'resourceGroup'
@description('The name of the account to create/update. If the account already exists, it will be updated.')
param accountName string
@description('The name of the owner of this account, usually a Microsoft alias, but can be any string.')
param ownerName string
@description('The Azure location in which to create the account.')
param location string
@description('The total throughput limit for the account. Defaults to 10000 RU/s.')
param totalThroughputLimit int = 10000
@allowed([
'tables'
'cassandra'
'gremlin'
'mongo'
'mongo32'
'sql'
])
@description('The type of account to create.')
param testAccountType string
var kind = (testAccountType == 'mongo' || testAccountType == 'mongo32') ? 'MongoDB' : 'GlobalDocumentDB'
var capabilities = (testAccountType == 'tables') ? [{name: 'EnableTable'}] : (testAccountType == 'cassandra') ? [{name: 'EnableCassandra'}] : (testAccountType == 'gremlin') ? [{name: 'EnableGremlin'}] : []
var serverVersion = (testAccountType == 'mongo32') ? '3.2' : (testAccountType == 'mongo') ? '6.0' : null
resource testCosmosAccount 'Microsoft.DocumentDB/databaseAccounts@2024-02-15-preview' = {
name: accountName
location: location
tags: {
'DataExplorer:TestAccountType': testAccountType
Owner: ownerName
}
kind: kind
properties: {
databaseAccountOfferType: 'Standard'
locations: [
{
locationName: location
failoverPriority: 0
}
]
apiProperties: {
serverVersion: serverVersion
}
capabilities: capabilities
capacity: {
totalThroughputLimit: totalThroughputLimit
}
}
}

View File

@ -0,0 +1,31 @@
@description('A prefix to apply to the name of each account.')
param accountPrefix string
@description('The name of the owner of this account, usually a Microsoft alias, but can be any string.')
param ownerName string
@description('The Azure location in which to create the account.')
param location string
@description('The total throughput limit for the account. Defaults to 10000 RU/s.')
param totalThroughputLimit int = 10000
@allowed([
'tables'
'cassandra'
'gremlin'
'mongo'
'mongo32'
'sql'
])
@description('The type of accounts to create.')
param testAccountTypes string[]
var actualPrefix = endsWith(accountPrefix, '-') ? accountPrefix : '${accountPrefix}-'
module testAccount './account.bicep' = [for testAccountType in testAccountTypes: {
name: '${actualPrefix}${testAccountType}'
params: {
accountName: '${actualPrefix}${testAccountType}'
ownerName: ownerName
location: location
totalThroughputLimit: totalThroughputLimit
testAccountType: testAccountType
}
}]

View File

@ -0,0 +1,31 @@
param(
[Parameter(Mandatory=$true)][string]$Subscription,
[Parameter(Mandatory=$false)][string]$ResourceGroupName,
[Parameter(Mandatory=$true)][string]$Location
)
Import-Module "Az.Accounts" -Scope Local
Import-Module "Az.Resources" -Scope Local
$AzSubscription = (Get-AzSubscription -SubscriptionId $Subscription -ErrorAction SilentlyContinue) ?? (Get-AzSubscription -SubscriptionName $Subscription -ErrorAction SilentlyContinue)
if(-not $AzSubscription) {
throw "The subscription '$Subscription' could not be found."
}
Select-AzSubscription -SubscriptionId $Subscription
if(-not $ResourceGroupName) {
$ResourceGroupName = $env:USERNAME + "-e2e-testing"
}
$AzResourceGroup = Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction SilentlyContinue
if($AzResourceGroup) {
Write-Host "The resource group '$ResourceGroupName' already exists."
return
}
$confirm = Read-Host "Do you want to create the resource group '$ResourceGroupName' in the location '$Location'? (y/n)"
if($confirm -ne "y") {
throw "The resource group creation was cancelled."
}
$AzResourceGroup = New-AzResourceGroup -Name $ResourceGroupName -Location $Location

113
test/resources/deploy.ps1 Normal file
View File

@ -0,0 +1,113 @@
param(
[Parameter(Mandatory=$false)][string]$ResourceGroup,
[Parameter(Mandatory=$false)][string]$Subscription,
[Parameter(Mandatory=$false)][string]$Location,
[Parameter(Mandatory=$false)][string]$ResourcePrefix,
[ValidateSet("tables", "cassandra", "gremlin", "mongo", "mongo32", "sql")] # This must be a constant so we can't re-use the $AllResourceTypes variable :(
[Parameter(Mandatory=$false)][string[]]$ResourceSets,
[Parameter(Mandatory=$false)][string]$OwnerName,
[Parameter(Mandatory=$false)][int]$TotalThroughputLimit = 10000,
[Parameter(Mandatory=$false)][switch]$WhatIf
)
$AllResourceTypes = @(
"tables",
"cassandra",
"gremlin",
"mongo",
"mongo32",
"sql"
)
Import-Module "Az.Accounts" -Scope Local
Import-Module "Az.Resources" -Scope Local
if (-not (Get-Command bicep -ErrorAction SilentlyContinue)) {
throw "The Bicep CLI is required to run this script. Please install it first."
}
if (-not (Get-AzContext)) {
throw "Please login to your Azure account using Connect-AzAccount before running this script."
}
if(-not $ResourcePrefix) {
$ResourcePrefix = $env:USERNAME + "-e2e-"
}
if(-not $OwnerName) {
$OwnerName = $env:USERNAME
}
if (-not $Subscription) {
# Show the user the currently-selected subscription and ask if that's what they want to use
$currentSubscription = Get-AzContext | Select-Object -ExpandProperty Subscription
Write-Host "The currently-selected subscription is $($currentSubscription.Name) ($($currentSubscription.Id))."
$useCurrentSubscription = Read-Host "Do you want to use this subscription? (y/n)"
if ($useCurrentSubscription -eq "n") {
throw "Either specify a subscription using '-Subscription' or select a subscription using 'Select-AzSubscription' before running this script."
}
$Subscription = $currentSubscription.Id
}
$AzSubscription = (Get-AzSubscription -SubscriptionId $Subscription -ErrorAction SilentlyContinue) ?? (Get-AzSubscription -SubscriptionName $Subscription -ErrorAction SilentlyContinue)
if (-not $AzSubscription) {
throw "The subscription '$Subscription' could not be found."
}
Set-AzContext $AzSubscription | Out-Null
if (-not $ResourceGroup) {
$DefaultResourceGroupName = $env:USERNAME + "-e2e-testing"
if (Get-AzResourceGroup -Name $DefaultResourceGroupName -ErrorAction SilentlyContinue) {
Write-Host "Found a resource group with the default name ($DefaultResourceGroupName). Using that resource group. If you want to use a different resource group, specify it as a parameter."
$ResourceGroup = $DefaultResourceGroupName
} else {
$ResourceGroup = Read-Host "Specify the name of the resource group to deploy the resources to."
}
}
$AzResourceGroup = Get-AzResourceGroup -Name $ResourceGroup -ErrorAction SilentlyContinue
if (-not $AzResourceGroup) {
throw "The resource group '$ResourceGroup' could not be found. You have to create the resource group manually before running this script."
}
if (-not $Location) {
$Location = $AzResourceGroup.Location
}
$AzLocation = Get-AzLocation | Where-Object { $_.Location -eq $Location }
if (-not $AzLocation) {
throw "The location '$Location' could not be found."
}
if (-not $ResourceSets) {
$ResourceSets = $AllResourceTypes
} else {
# Normalize the resource set names to the value in AllResourceTypes
$ResourceSets = $ResourceSets | ForEach-Object { $_.ToLower() }
}
Write-Host "Deploying test resource sets: $ResourceSets"
Write-Host " in $($AzLocation.DisplayName)"
Write-Host " to resource group $ResourceGroup"
Write-Host " in subscription $($AzSubscription.Name) ($($AzSubscription.Id))."
if($WhatIf) {
Write-Host " (What-If mode enabled)"
}
$continue = Read-Host "Do you want to continue? (y/n)"
if ($continue -ne "y") {
throw "Deployment cancelled."
}
$bicepFile = Join-Path $PSScriptRoot "all-accounts.bicep"
Write-Host "Deploying resources using $bicepFile"
New-AzResourceGroupDeployment `
-ResourceGroupName $AzResourceGroup.ResourceGroupName `
-TemplateFile $bicepFile `
-WhatIf:$WhatIf `
-accountPrefix $ResourcePrefix `
-testAccountTypes $ResourceSets `
-location $AzLocation.Location `
-ownerName $OwnerName `
-totalThroughputLimit $TotalThroughputLimit

View File

@ -0,0 +1,22 @@
Write-Host "Your test account configuration:"
Import-Module "Az.Accounts" -Scope Local
Import-Module "Az.Resources" -Scope Local
Get-ChildItem env:\DE_TEST_* | ForEach-Object {
if ($_.Name -eq "DE_TEST_SUBSCRIPTION_ID") {
$AzSubscription = Get-AzSubscription -SubscriptionId $_.Value -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $AzSubscription) {
Write-Warning "The subscription '$($_.Value)' could not be found."
}
Write-Host "* $($_.Name): $($_.Value) ($($AzSubscription.Name))"
} elseif ($_.Name -eq "DE_TEST_RESOURCE_GROUP") {
$AzResourceGroup = Get-AzResourceGroup -Name $_.Value -ErrorAction SilentlyContinue
if (-not $AzResourceGroup) {
Write-Warning "The resource group '$($_.Value)' could not be found."
}
Write-Host "* $($_.Name): $($_.Value) (Confirmed)"
} else {
Write-Host "* $($_.Name): $($_.Value)"
}
}

View File

@ -0,0 +1,69 @@
param(
[Parameter(Mandatory=$false)][string]$ResourceGroup,
[Parameter(Mandatory=$false)][string]$Subscription,
[Parameter(Mandatory=$false)][string]$ResourcePrefix
)
Import-Module "Az.Accounts" -Scope Local
Import-Module "Az.Resources" -Scope Local
if (-not $Subscription) {
# Show the user the currently-selected subscription and ask if that's what they want to use
$currentSubscription = Get-AzContext | Select-Object -ExpandProperty Subscription
Write-Host "The currently-selected subscription is $($currentSubscription.Name) ($($currentSubscription.Id))."
$useCurrentSubscription = Read-Host "Do you want to use this subscription? (y/n)"
if ($useCurrentSubscription -eq "n") {
throw "Either specify a subscription using '-Subscription' or select a subscription using 'Select-AzSubscription' before running this script."
}
$Subscription = $currentSubscription.Id
}
$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."
}
Set-AzContext $AzSubscription.Id | Out-Null
if (-not $ResourceGroup) {
# Check for the default resource group name
$DefaultResourceGroupName = $env:USERNAME + "-e2e-testing"
if (Get-AzResourceGroup -Name $DefaultResourceGroupName -ErrorAction SilentlyContinue) {
$ResourceGroup = $DefaultResourceGroupName
} else {
$ResourceGroup = Read-Host "Specify the name of the resource group to find the resources in."
}
}
$AzResourceGroup = Get-AzResourceGroup -Name $ResourceGroup -ErrorAction SilentlyContinue
if (-not $AzResourceGroup) {
throw "The resource group '$ResourceGroup' could not be found. You have to create the resource group manually before running this script."
}
if (-not $ResourcePrefix) {
$defaultResourcePrefix = $env:USERNAME + "-e2e-"
# Check for one of the default resources
$defaultResource = Get-AzResource -ResourceGroupName $AzResourceGroup.ResourceGroupName -ResourceName "$($defaultResourcePrefix)cassandra" -ResourceType "Microsoft.DocumentDB/databaseAccounts" -ErrorAction SilentlyContinue
if ($defaultResource) {
Write-Host "Found a resource with the default resource prefix ($defaultResourcePrefix). Configuring that prefix for E2E testing."
$ResourcePrefix = $defaultResourcePrefix
} else {
$ResourcePrefix = Read-Host "Specify the resource prefix used in the resource names."
}
}
Write-Host "Configuring for E2E Testing"
Write-Host " Subscription: $($AzSubscription.Name) ($($AzSubscription.Id))"
Write-Host " Resource Group: $($AzResourceGroup.ResourceGroupName)"
Write-Host " Resource Prefix: $ResourcePrefix"
Get-AzResource -ResourceGroupName $AzResourceGroup.ResourceGroupName -ResourceType "Microsoft.DocumentDB/databaseAccounts" -ErrorAction SilentlyContinue | ForEach-Object {
if ($_.Name -like "$ResourcePrefix*") {
Write-Host " Found CosmosDB Account: $($_.Name)"
}
}
$env:DE_TEST_RESOURCE_GROUP = $AzResourceGroup.ResourceGroupName
$env:DE_TEST_SUBSCRIPTION_ID = $AzSubscription.Id
$env:DE_TEST_ACCOUNT_PREFIX = $ResourcePrefix

View File

@ -1,41 +0,0 @@
import { getAzureCLICredentialsToken } from "../utils/shared";
test("Self Serve", async () => {
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
await page.goto(`https://localhost:1234/testExplorer.html?iframeSrc=selfServe.html&token=${token}`);
const handle = await page.waitForSelector("iframe");
const frame = await handle.contentFrame();
// wait for refresh RP call to end
await page.waitForTimeout(10000);
// id of the display element is in the format {PROPERTY_NAME}-{DISPLAY_NAME}-{DISPLAY_TYPE}
await frame.waitForSelector("#description-text-display");
const regions = await frame.waitForSelector("#regions-dropdown-input");
const currentRegionsDescription = await frame.$$("#currentRegionText-text-display");
expect(currentRegionsDescription).toHaveLength(0);
let disabledLoggingToggle = await frame.$$("#enableLogging-toggle-input[disabled]");
expect(disabledLoggingToggle).toHaveLength(0);
await regions.click();
const regionsDropdownElement1 = await frame.waitForSelector("#regions-dropdown-input-list0");
await regionsDropdownElement1.click();
await frame.waitForSelector("#currentRegionText-text-display");
disabledLoggingToggle = await frame.$$("#enableLogging-toggle-input[disabled]");
expect(disabledLoggingToggle).toHaveLength(1);
await frame.waitForSelector("#accountName-textField-input");
const enableDbLevelThroughput = await frame.waitForSelector("#enableDbLevelThroughput-toggle-input");
const dbThroughput = await frame.$$("#dbThroughput-slider-input");
expect(dbThroughput).toHaveLength(0);
await enableDbLevelThroughput.click();
await frame.waitForSelector("#dbThroughput-slider-input");
await frame.waitForSelector("#collectionThroughput-spinner-input");
});

View File

@ -1,55 +1,40 @@
import { jest } from "@jest/globals";
import "expect-playwright";
import {
AccountType,
generateUniqueName,
getPanelSelector,
getTestExplorerUrl,
getTreeMenuItemSelector,
getTreeNodeSelector,
openContextMenu,
} from "../utils/shared";
import { waitForExplorer } from "../utils/waitForExplorer";
jest.setTimeout(120000);
import { expect, test } from "@playwright/test";
test("SQL CRUD", async () => {
const databaseId = generateUniqueName("db");
import { DataExplorer, TestAccount, generateDatabaseNameWithTimestamp, generateUniqueName } from "../fx";
test("SQL database and container CRUD", async ({ page }) => {
const databaseId = generateDatabaseNameWithTimestamp();
const containerId = generateUniqueName("container");
page.setDefaultTimeout(50000);
const explorer = await DataExplorer.open(page, TestAccount.SQL);
const url = await getTestExplorerUrl(AccountType.SQL);
await page.goto(url);
const explorer = await waitForExplorer();
await explorer.commandBarButton("New Container").click();
await explorer.whilePanelOpen("New Container", async (panel, okButton) => {
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
await panel.getByRole("textbox", { name: "Container id, Example Container1" }).fill(containerId);
await panel.getByRole("textbox", { name: "Partition key" }).fill("/pk");
await panel.getByLabel("Database max RU/s").fill("1000");
await okButton.click();
});
await explorer.click('[data-test="New Container"]');
const databaseNode = explorer.treeNode(`DATA/${databaseId}`);
await databaseNode.expand();
const containerNode = explorer.treeNode(`DATA/${databaseId}/${containerId}`);
await explorer.waitForSelector(getPanelSelector("New Container"));
await explorer.fill('[aria-label="New database id, Type a new database id"]', databaseId);
await explorer.fill('[aria-label="Container id, Example Container1"]', containerId);
await explorer.fill('[aria-label="Partition key"]', "/pk");
await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("New Container"), { state: "detached" });
await containerNode.openContextMenu();
await containerNode.contextMenuItem("Delete Container").click();
await explorer.whilePanelOpen("Delete Container", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the container id" }).fill(containerId);
await okButton.click();
});
await expect(containerNode.element).not.toBeAttached();
await explorer.click(getTreeNodeSelector(`DATA/${databaseId}`));
await explorer.hover(getTreeNodeSelector(`DATA/${databaseId}/${containerId}`));
await openContextMenu(explorer, `DATA/${databaseId}/${containerId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${databaseId}/${containerId}`, "Delete Container"));
await databaseNode.openContextMenu();
await databaseNode.contextMenuItem("Delete Database").click();
await explorer.whilePanelOpen("Delete Database", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the database id" }).fill(databaseId);
await okButton.click();
});
await explorer.waitForSelector(getPanelSelector("Delete Container"));
await explorer.fill('text=* Confirm by typing the container id >> input[type="text"]', containerId);
await explorer.click('[aria-label="OK"]');
await explorer.waitForSelector(getPanelSelector("Delete Container"), { state: "detached" });
await openContextMenu(explorer, `DATA/${databaseId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/${databaseId}`, "Delete Database"));
await explorer.waitForSelector(getPanelSelector("Delete Database"));
await explorer.click('text=* Confirm by typing the database id >> input[type="text"]');
await explorer.fill('text=* Confirm by typing the database id >> input[type="text"]', databaseId);
await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("Delete Database"), { state: "detached" });
await expect(explorer).not.toHaveText(".dataResourceTree", databaseId);
await expect(explorer).not.toHaveText(".dataResourceTree", containerId);
await expect(databaseNode.element).not.toBeAttached();
});

View File

@ -1,19 +1,23 @@
import { expect, test } from "@playwright/test";
import { CosmosDBManagementClient } from "@azure/arm-cosmosdb";
import { CosmosClient, PermissionMode } from "@azure/cosmos";
import { jest } from "@jest/globals";
import "expect-playwright";
import { generateUniqueName, getAzureCLICredentials, getTreeNodeSelector } from "../utils/shared";
import { waitForExplorer } from "../utils/waitForExplorer";
jest.setTimeout(120000);
import {
DataExplorer,
TestAccount,
generateUniqueName,
getAccountName,
getAzureCLICredentials,
resourceGroupName,
subscriptionId,
} from "../fx";
const subscriptionId = process.env["AZURE_SUBSCRIPTION_ID"] ?? "";
const resourceGroupName = "runners";
test("Resource token", async () => {
test("SQL account using Resource token", async ({ page }) => {
const credentials = await getAzureCLICredentials();
const armClient = new CosmosDBManagementClient(credentials, subscriptionId);
const account = await armClient.databaseAccounts.get(resourceGroupName, "portal-sql-runner-west-us");
const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, "portal-sql-runner-west-us");
const accountName = getAccountName(TestAccount.SQL);
const account = await armClient.databaseAccounts.get(resourceGroupName, accountName);
const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName);
const dbId = generateUniqueName("db");
const collectionId = generateUniqueName("col");
const client = new CosmosClient({
@ -28,15 +32,24 @@ test("Resource token", async () => {
permissionMode: PermissionMode.All,
resource: container.url,
});
const resourceTokenConnectionString = `AccountEndpoint=${account.documentEndpoint};DatabaseId=${database.id};CollectionId=${container.id};${containerPermission._token}`;
await expect(containerPermission).toBeDefined();
const resourceTokenConnectionString = `AccountEndpoint=${account.documentEndpoint};DatabaseId=${
database.id
};CollectionId=${container.id};${containerPermission!._token}`;
await page.goto("https://localhost:1234/hostedExplorer.html");
await page.waitForSelector("div > p.switchConnectTypeText");
await page.click("div > p.switchConnectTypeText");
await page.fill("input[class='inputToken']", resourceTokenConnectionString);
await page.click("input[value='Connect']");
const explorer = await waitForExplorer();
const switchConnectionLink = page.getByTestId("Link:SwitchConnectionType");
await switchConnectionLink.waitFor();
await switchConnectionLink.click();
await page.getByPlaceholder("Please enter a connection string").fill(resourceTokenConnectionString);
await page.getByRole("button", { name: "Connect" }).click();
const collectionNodeLabel = await explorer.textContent(getTreeNodeSelector(`DATA/${collectionId}`));
expect(collectionNodeLabel).toBe(collectionId);
const explorer = await DataExplorer.waitForExplorer(page);
const collectionNode = explorer.treeNode(`DATA/${collectionId}`);
await collectionNode.element.waitFor();
await expect(collectionNode.element).toBeAttached();
await database.delete();
});

View File

@ -0,0 +1,23 @@
import { expect, test } from "@playwright/test";
import { DataExplorer, TestAccount } from "../fx";
test("Self Serve", async ({ page }) => {
const explorer = await DataExplorer.open(page, TestAccount.SQL, "selfServe.html");
const loggingToggle = explorer.frame.locator("#enableLogging-toggle-input");
await expect(loggingToggle).toBeEnabled();
const regionDropdown = explorer.frame.getByText("Select a region");
await regionDropdown.click();
await explorer.frame.getByRole("option").first().click();
const currentRegionLabel = explorer.frame.getByLabel("Current Region");
await currentRegionLabel.waitFor();
await expect(currentRegionLabel).toHaveText(/current region selected is .*/);
await expect(loggingToggle).toBeDisabled();
await explorer.frame.locator("#enableDbLevelThroughput-toggle-input").click();
const slider = explorer.frame.getByLabel("Database Throughput");
await slider.waitFor();
await expect(slider).toBeAttached();
});

View File

@ -1,40 +1,28 @@
import { jest } from "@jest/globals";
import "expect-playwright";
import {
AccountType,
generateUniqueName,
getPanelSelector,
getTestExplorerUrl,
getTreeMenuItemSelector,
openContextMenu,
} from "../utils/shared";
import { waitForExplorer } from "../utils/waitForExplorer";
import { expect, test } from "@playwright/test";
jest.setTimeout(120000);
import { DataExplorer, TestAccount, generateUniqueName } from "../fx";
test("Tables CRUD", async () => {
test("Tables CRUD", async ({ page }) => {
const tableId = generateUniqueName("table");
page.setDefaultTimeout(50000);
const url = await getTestExplorerUrl(AccountType.Tables);
await page.goto(url);
const explorer = await waitForExplorer();
const explorer = await DataExplorer.open(page, TestAccount.Tables);
await page.waitForSelector('text="Querying databases"', { state: "detached" });
await explorer.click('[data-test="New Table"]');
await explorer.commandBarButton("New Table").click();
await explorer.whilePanelOpen("New Table", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Table id, Example Table1" }).fill(tableId);
await panel.getByLabel("Table Max RU/s").fill("1000");
await okButton.click();
});
await explorer.waitForSelector(getPanelSelector("New Table"));
await explorer.fill('[aria-label="Table id, Example Table1"]', tableId);
await explorer.click("#sidePanelOkButton");
await explorer.waitForSelector(getPanelSelector("New Table"), { state: "detached" });
const tableNode = explorer.treeNode(`DATA/TablesDB/${tableId}`);
await expect(tableNode.element).toBeAttached();
await openContextMenu(explorer, `DATA/TablesDB/${tableId}`);
await explorer.click(getTreeMenuItemSelector(`DATA/TablesDB/${tableId}`, "Delete Table"));
await tableNode.openContextMenu();
await tableNode.contextMenuItem("Delete Table").click();
await explorer.whilePanelOpen("Delete Table", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the table id" }).fill(tableId);
await okButton.click();
});
await explorer.waitForSelector(getPanelSelector("Delete Table"));
await explorer.fill('text=* Confirm by typing the table id >> input[type="text"]', tableId);
await explorer.click('[aria-label="OK"]');
await explorer.waitForSelector(getPanelSelector("Delete Table"), { state: "detached" });
await expect(explorer).not.toHaveText(".dataResourceTree", tableId);
await expect(tableNode.element).not.toBeAttached();
});

View File

@ -23,6 +23,11 @@ const initTestExplorer = async (): Promise<void> => {
const databaseAccount = await get(subscriptionId, resourceGroup, accountName);
const keys = await listKeys(subscriptionId, resourceGroup, accountName);
// Disable the quickstart carousel.
if (databaseAccount?.id) {
localStorage.setItem(databaseAccount.id, "true");
}
const initTestExplorerContent = {
inputs: {
databaseAccount: databaseAccount,
@ -65,6 +70,10 @@ const initTestExplorer = async (): Promise<void> => {
// This simulates the same action that happens in the portal
console.dir(event.data);
if (event.data?.kind === "ready") {
if (!iframe.contentWindow || !iframe.contentDocument) {
throw new Error("iframe is not loaded");
}
iframe.contentWindow.postMessage(
{
signature: "pcIframe",
@ -78,6 +87,7 @@ const initTestExplorer = async (): Promise<void> => {
);
iframe.id = "explorerMenu";
iframe.name = "explorer";
iframe.setAttribute("data-test", "DataExplorerFrame");
iframe.classList.add("iframe");
iframe.title = "explorer";
iframe.src = iframeSrc;

View File

@ -1,21 +0,0 @@
let client;
const appInsightsKey = process.env.PORTAL_RUNNER_APP_INSIGHTS_KEY;
if (!appInsightsKey) {
console.warn(`PORTAL_RUNNER_APP_INSIGHTS_KEY env var not set. Runner telemetry will not be reported`);
} else {
const appInsights = require("applicationinsights");
appInsights.setup(process.env.PORTAL_RUNNER_APP_INSIGHTS_KEY).start();
client = appInsights.defaultClient;
}
module.exports.trackEvent = (...args) => {
if (client) {
client.trackEvent(...args);
}
};
module.exports.trackException = exception => {
if (client) {
client.trackException({ exception });
}
};

View File

@ -1,69 +0,0 @@
import { AzureCliCredentials } from "@azure/ms-rest-nodeauth";
import crypto from "crypto";
import { Frame } from "playwright";
export enum AccountType {
Tables = "Tables",
Cassandra = "Cassandra",
Gremlin = "Gremlin",
Mongo = "Mongo",
Mongo32 = "Mongo32",
SQL = "SQL",
}
export const defaultAccounts: Record<AccountType, string> = {
[AccountType.Tables]: "portal-tables-runner",
[AccountType.Cassandra]: "portal-cassandra-runner",
[AccountType.Gremlin]: "portal-gremlin-runner",
[AccountType.Mongo]: "portal-mongo-runner",
[AccountType.Mongo32]: "portal-mongo32-runner",
[AccountType.SQL]: "portal-sql-runner-west-us",
};
const resourceGroup = process.env.DE_TEST_RESOURCE_GROUP ?? "runners";
const subscriptionId = process.env.DE_TEST_SUBSCRIPTION_ID ?? "69e02f2d-f059-4409-9eac-97e8a276ae2c";
export async function getTestExplorerUrl(accountType: AccountType) {
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
const accountName =
process.env[`DE_TEST_ACCOUNT_NAME_${accountType.toLocaleUpperCase()}`] ?? defaultAccounts[accountType];
return `https://localhost:1234/testExplorer.html?accountName=${accountName}&resourceGroup=${resourceGroup}&subscriptionId=${subscriptionId}&token=${token}`;
}
export function generateUniqueName(baseName = "", length = 4): string {
return `${baseName}${crypto.randomBytes(length).toString("hex")}`;
}
export function generateDatabaseNameWithTimestamp(baseName = "db", length = 1): string {
return `${baseName}${crypto.randomBytes(length).toString("hex")}-${Date.now()}`;
}
export async function getAzureCLICredentials(): Promise<AzureCliCredentials> {
return await AzureCliCredentials.create();
}
export async function getAzureCLICredentialsToken(): Promise<string> {
const credentials = await getAzureCLICredentials();
const token = (await credentials.getToken()).accessToken;
return token;
}
export function getPanelSelector(title: string) {
return `[data-test="Panel:${title}"]`;
}
export function getTreeNodeSelector(id: string) {
return `[data-test="TreeNode:${id}"]`;
}
export function getTreeMenuItemSelector(nodeId: string, itemLabel: string) {
return `[data-test="TreeNode/ContextMenu:${nodeId}"] [data-test="TreeNode/ContextMenuItem:${itemLabel}"]`;
}
export async function openContextMenu(explorer: Frame, nodeIdentifier: string) {
const nodeSelector = getTreeNodeSelector(nodeIdentifier);
await explorer.hover(nodeSelector);
await explorer.click(`${nodeSelector} [data-test="TreeNode/ContextMenuTrigger"]`);
await explorer.waitForSelector(`[data-test="TreeNode/ContextMenu:${nodeIdentifier}"]`);
}

View File

@ -1,9 +0,0 @@
import { Frame } from "playwright";
export const waitForExplorer = async (): Promise<Frame> => {
await page.waitForSelector("iframe");
await page.waitForTimeout(5000);
return page.frame({
name: "explorer",
});
};

View File

@ -87,6 +87,7 @@ const typescriptRule = {
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
/** @type {(_env: Record<string, string>, argv: Record<string, unknown>) => import("webpack").Configuration} */
module.exports = function (_env = {}, argv = {}) {
const mode = argv.mode || "development";
const rules = [fontRule, lessRule, imagesRule, cssRule, htmlRule, typescriptRule];
@ -265,6 +266,8 @@ module.exports = function (_env = {}, argv = {}) {
watch: false,
// Hack since it is hard to disable watch entirely with webpack dev server https://github.com/webpack/webpack-dev-server/issues/1251#issuecomment-654240734
watchOptions: isCI ? { poll: 24 * 60 * 60 * 1000 } : {},
/** @type {import("webpack-dev-server").Configuration}*/
devServer: {
hot: false,
// disableHostCheck is removed in webpack 5, use: allowedHosts: "all",
@ -282,6 +285,26 @@ module.exports = function (_env = {}, argv = {}) {
"Access-Control-Allow-Headers": "*",
"Access-Control-Allow-Methods": "*",
},
setupMiddlewares: (middlewares, server) => {
// Provide an HTTP API that will wait for compilation of all bundles to be completed.
// This is used by Playwright to know when the server is ready to be tested.
let compilationComplete = false;
server.compiler.hooks.done.tap("done", () => {
setImmediate(() => {
compilationComplete = true;
});
});
server.app.get("/_ready", (_, res) => {
if (compilationComplete) {
res.status(200).send("Compilation complete.");
} else {
res.status(503).send("Compilation not complete.");
}
});
return middlewares;
},
proxy: {
"/api": {
target: "https://main.documentdb.ext.azure.com",