Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,27 @@ jobs:
run: npm run test
working-directory: ./src/vscode-bicep-ui

- name: Install Playwright browsers (visual-designer)
if: runner.os == 'Linux'
run: npm run e2e:install
working-directory: ./src/vscode-bicep-ui/apps/visual-designer

- name: Run Playwright E2E tests (visual-designer)
if: runner.os == 'Linux'
run: npm run e2e
working-directory: ./src/vscode-bicep-ui/apps/visual-designer
env:
CI: "true"

- name: Upload Playwright report (visual-designer)
if: always() && runner.os == 'Linux'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: visual-designer-playwright-report
path: ./src/vscode-bicep-ui/apps/visual-designer/e2e/.report
if-no-files-found: ignore
retention-days: 7

test-vscode-ext:
name: "Test: VSCode Extension"
runs-on: ${{ matrix.os }}
Expand Down
8 changes: 8 additions & 0 deletions src/vscode-bicep-ui/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
node_modules
.turbo
*.log
*.tsbuildinfo
dist
dist-ssr
storybook-static
Expand All @@ -12,4 +13,11 @@ server/dist
public/dist
coverage

# Playwright
**/e2e/.report/
**/e2e/.results/
playwright-report
test-results
.playwright

!packages/*
43 changes: 43 additions & 0 deletions src/vscode-bicep-ui/apps/visual-designer/e2e/app-loads.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { expect, test } from "@playwright/test";
import { openVisualDesigner } from "./fixtures";

test.describe("Application bootstrap", () => {
test("loads the visual designer shell", async ({ page }) => {
await page.goto("/");

await expect(page).toHaveTitle(/Visual Designer/i);
await expect(page.getByTestId("app-root")).toBeVisible();
await expect(page.getByTestId("graph-canvas")).toBeVisible();
await expect(page.getByTestId("control-bar")).toBeVisible();
await expect(page.getByTestId("status-bar")).toBeVisible();
});

test("renders without console errors", async ({ page }) => {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
errors.push(msg.text());
}
});
page.on("pageerror", (err) => errors.push(err.message));

await openVisualDesigner(page);

// Filter out errors that are not actionable for E2E (e.g. dev-only
// resource fetch warnings from external CDNs). Surface anything else.
const significant = errors.filter(
(text) => !/failed to load resource/i.test(text) && !/codicon/i.test(text),
);
expect(significant, `Unexpected console errors:\n${significant.join("\n")}`).toEqual([]);
});

test("auto-receives the default deployment graph from the fake channel", async ({ page }) => {
await openVisualDesigner(page);

const count = await page.getByTestId("graph-node").count();
expect(count).toBeGreaterThan(0);
});
});
114 changes: 114 additions & 0 deletions src/vscode-bicep-ui/apps/visual-designer/e2e/controls.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { expect, test } from "@playwright/test";
import { getGraphTransform, loadSampleGraph, openVisualDesigner } from "./fixtures";

test.describe("Status bar", () => {
test.beforeEach(async ({ page }) => {
await openVisualDesigner(page);
});

test("reports a ready state for a healthy graph", async ({ page }) => {
await loadSampleGraph(page, "flat");

await expect(page.getByTestId("status-bar")).toHaveAttribute("data-status", "ready");
await expect(page.getByTestId("status-error-link")).toHaveCount(0);
await expect(page.getByTestId("status-empty-message")).toHaveCount(0);
});

test("surfaces the error count for a graph that has diagnostics", async ({ page }) => {
await loadSampleGraph(page, "error");

const statusBar = page.getByTestId("status-bar");
await expect(statusBar).toHaveAttribute("data-status", "errors");
await expect(statusBar).toHaveAttribute("data-error-count", "3");
await expect(page.getByTestId("status-error-link")).toHaveText(/3\s+errors/);
});

test("shows the empty-state message when the graph is null", async ({ page }) => {
await loadSampleGraph(page, "empty");

await expect(page.getByTestId("status-bar")).toHaveAttribute("data-status", "empty");
await expect(page.getByTestId("status-empty-message")).toBeVisible();
});
});

test.describe("Control bar", () => {
test.beforeEach(async ({ page }) => {
await openVisualDesigner(page);
});

test("exposes all controls and disables graph-dependent ones when empty", async ({ page }) => {
await expect(page.getByTestId("control-zoom-in")).toBeEnabled();
await expect(page.getByTestId("control-zoom-out")).toBeEnabled();

await loadSampleGraph(page, "empty");

await expect(page.getByTestId("control-fit-view")).toBeDisabled();
await expect(page.getByTestId("control-reset-layout")).toBeDisabled();
await expect(page.getByTestId("control-export")).toBeDisabled();
});

test("re-enables graph-dependent controls when a graph is loaded", async ({ page }) => {
await loadSampleGraph(page, "empty");
await expect(page.getByTestId("control-fit-view")).toBeDisabled();

await loadSampleGraph(page, "flat");
await expect(page.getByTestId("control-fit-view")).toBeEnabled();
await expect(page.getByTestId("control-reset-layout")).toBeEnabled();
await expect(page.getByTestId("control-export")).toBeEnabled();
});

test("zoom in changes the pan-zoom transform", async ({ page }) => {
await loadSampleGraph(page, "flat");

const before = await getGraphTransform(page);
await page.getByTestId("control-zoom-in").click();
// The pan-zoom transform updates synchronously after the click,
// but allow a frame for the styled-component to flush.
await expect
.poll(async () => await getGraphTransform(page), { timeout: 5_000 })
.not.toBe(before);
});

test("zoom out also changes the pan-zoom transform", async ({ page }) => {
await loadSampleGraph(page, "flat");

const before = await getGraphTransform(page);
await page.getByTestId("control-zoom-out").click();
await expect
.poll(async () => await getGraphTransform(page), { timeout: 5_000 })
.not.toBe(before);
});

test("fit-view recenters the graph after zoom changes", async ({ page }) => {
await loadSampleGraph(page, "flat");

await page.getByTestId("control-zoom-in").click();
await page.getByTestId("control-zoom-in").click();
const zoomed = await getGraphTransform(page);

await page.getByTestId("control-fit-view").click();
await expect
.poll(async () => await getGraphTransform(page), { timeout: 5_000 })
.not.toBe(zoomed);
});
});

test.describe("Export overlay", () => {
test.beforeEach(async ({ page }) => {
await openVisualDesigner(page);
await loadSampleGraph(page, "flat");
});

test("opens via the export control and closes with Escape", async ({ page }) => {
await expect(page.getByTestId("export-overlay")).toHaveCount(0);

await page.getByTestId("control-export").click();
await expect(page.getByTestId("export-overlay")).toBeVisible();

await page.keyboard.press("Escape");
await expect(page.getByTestId("export-overlay")).toHaveCount(0);
});
});
72 changes: 72 additions & 0 deletions src/vscode-bicep-ui/apps/visual-designer/e2e/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import type { Page } from "@playwright/test";

import { expect } from "@playwright/test";

/**
* Sample graphs exposed by the dev toolbar. The slugs match the
* `data-testid` values generated in {@link DevToolbar.tsx}.
*/
export const SAMPLE_GRAPHS = {
module: { slug: "dev-graph-module-graph", label: "Module graph" },
flat: { slug: "dev-graph-flat-graph", label: "Flat graph" },
error: { slug: "dev-graph-error-graph", label: "Error graph" },
complex: { slug: "dev-graph-complex-graph", label: "Complex graph" },
empty: { slug: "dev-graph-empty-null", label: "Empty (null)" },
} as const;

export type SampleGraphKey = keyof typeof SAMPLE_GRAPHS;

/**
* Navigate to the visual designer and wait for the React app to mount
* and the initial sample graph (the dev fake channel pushes the
* "Module graph" 50 ms after the READY notification) to render.
*/
export async function openVisualDesigner(page: Page): Promise<void> {
await page.goto("/");
await expect(page.getByTestId("app-root")).toBeVisible();
await expect(page.getByTestId("graph-canvas")).toBeVisible();
await expect(page.getByTestId("dev-toolbar")).toBeVisible();
await waitForAnyNode(page);
}

/**
* Click a sample-graph button in the dev toolbar and wait for the
* graph to settle: nodes must be present (or absent, for the empty
* sample) and the status bar must reflect the new state.
*/
export async function loadSampleGraph(page: Page, key: SampleGraphKey): Promise<void> {
const { slug } = SAMPLE_GRAPHS[key];
await page.getByTestId(slug).click();

if (key === "empty") {
await expect(page.getByTestId("graph-node")).toHaveCount(0);
await expect(page.getByTestId("status-bar")).toHaveAttribute("data-status", "empty");
return;
}

await waitForAnyNode(page);
}

/** Wait until at least one graph node has been laid out and is visible. */
export async function waitForAnyNode(page: Page): Promise<void> {
await expect(page.getByTestId("graph-node").first()).toBeVisible();
}

/** Return the count of graph nodes currently rendered. */
export function nodeCount(page: Page): Promise<number> {
return page.getByTestId("graph-node").count();
}

/** Return the current pan-zoom transform on the inner graph layer. */
export async function getGraphTransform(page: Page): Promise<string> {
return page.evaluate(() => {
const layer = document.querySelector<HTMLElement>(
'[data-testid="graph-canvas"] [style*="transform"]',
);
if (!layer) return "";
return layer.style.transform || getComputedStyle(layer).transform;
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { expect, test } from "@playwright/test";
import { loadSampleGraph, nodeCount, openVisualDesigner } from "./fixtures";

test.describe("Graph rendering", () => {
test.beforeEach(async ({ page }) => {
await openVisualDesigner(page);
});

test("renders the flat graph with the expected resource nodes", async ({ page }) => {
await loadSampleGraph(page, "flat");

// The flat sample has four atomic resources: vnet, subnet, nsg, pip.
const atomicNodes = page.locator('[data-testid="graph-node"][data-node-kind="atomic"]');
await expect(atomicNodes).toHaveCount(4);

for (const id of ["vnet", "subnet", "nsg", "pip"]) {
await expect(page.locator(`[data-node-id="${id}"]`)).toBeVisible();
}
});

test("renders compound (module) nodes for the module graph", async ({ page }) => {
await loadSampleGraph(page, "module");

const compoundNodes = page.locator('[data-testid="graph-node"][data-node-kind="compound"]');
await expect(compoundNodes).toHaveCount(1);
await expect(page.locator('[data-node-id="myModule"]')).toBeVisible();

// The module contains two children, plus there are two top-level
// atomic nodes.
const atomicNodes = page.locator('[data-testid="graph-node"][data-node-kind="atomic"]');
await expect(atomicNodes).toHaveCount(4);
});

test("renders a richer topology for the complex graph", async ({ page }) => {
await loadSampleGraph(page, "complex");

// The complex sample contains 13 modules + 2 top-level resource groups.
const compoundNodes = page.locator('[data-testid="graph-node"][data-node-kind="compound"]');
await expect(compoundNodes).toHaveCount(13);

const total = await nodeCount(page);
expect(total).toBeGreaterThan(20);
});

test("renders an empty canvas for a null graph", async ({ page }) => {
await loadSampleGraph(page, "empty");

await expect(page.getByTestId("graph-node")).toHaveCount(0);
await expect(page.getByTestId("status-bar")).toHaveAttribute("data-status", "empty");
await expect(page.getByTestId("status-empty-message")).toBeVisible();
});

test("swaps the graph in-place when a new sample is loaded", async ({ page }) => {
await loadSampleGraph(page, "flat");
const flatCount = await nodeCount(page);

await loadSampleGraph(page, "complex");
const complexCount = await nodeCount(page);

expect(complexCount).toBeGreaterThan(flatCount);

await loadSampleGraph(page, "empty");
await expect(page.getByTestId("graph-node")).toHaveCount(0);
});
});
Loading
Loading