Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Identity] Two Playwright tests for the InteractiveBrowserCredential #21171

Closed
wants to merge 3 commits into from
Closed
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
1,355 changes: 1,040 additions & 315 deletions common/config/rush/pnpm-lock.yaml

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion sdk/identity/identity/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@
"check-format": "prettier --list-different --config ../../../.prettierrc.json --ignore-path ../../../.prettierignore \"src/**/*.ts\" \"test/**/*.ts\" \"samples-dev/**/*.ts\" \"*.{js,json}\"",
"integration-test:browser": "echo skipped",
"integration-test:node": "dev-tool run test:node-js-input -- --timeout 180000 'dist-esm/test/public/node/*.spec.js' 'dist-esm/test/internal/node/*.spec.js'",
"integration-test": "npm run integration-test:node && npm run integration-test:browser",
"integration-test:playwright": "rimraf dist-playwright && npx playwright install && tsc -p playwright.tsconfig.json && rollup --config && playwright test -c dist-playwright",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

npx playwright install ensures that the web browser needed for testing is installed. Playwright doesn’t do that by default when you install it.

"integration-test": "npm run integration-test:node && npm run integration-test:browser && npm run integration-test:playwright",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m having a hard time imagining how to merge this with our current setup 😅

"lint:fix": "eslint package.json api-extractor.json src test --ext .ts --fix --fix-type [problem,suggestion]",
"lint": "eslint package.json api-extractor.json src test --ext .ts",
"pack": "npm pack 2>&1",
Expand Down Expand Up @@ -126,16 +127,23 @@
"@azure/test-utils": "^1.0.0",
"@azure-tools/test-recorder": "^2.0.0",
"@microsoft/api-extractor": "^7.18.11",
"@playwright/test": "^1.19.2",
"@rollup/plugin-commonjs": "^21.0.2",
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^13.1.3",
"@rollup/plugin-typescript": "^8.3.1",
"@types/jws": "^3.2.2",
"@types/mocha": "^7.0.2",
"@types/node": "^12.0.0",
"@types/uuid": "^8.0.0",
"@types/chai": "^4.1.6",
"@types/express": "^4.17.13",
"@types/stoppable": "^1.1.0",
"chai": "^4.2.0",
"cross-env": "^7.0.2",
"dotenv": "^8.2.0",
"eslint": "^7.15.0",
"express": "^4.17.3",
"inherits": "^2.0.3",
"karma": "^6.2.0",
"karma-chrome-launcher": "^3.0.0",
Expand All @@ -151,6 +159,8 @@
"prettier": "^2.5.1",
"puppeteer": "^13.5.1",
"rimraf": "^3.0.0",
"rollup": "^2.70.1",
"rollup-plugin-shim": "^1.0.0",
"typescript": "~4.2.0",
"util": "^0.12.1",
"sinon": "^9.0.2",
Expand Down
10 changes: 10 additions & 0 deletions sdk/identity/identity/playwright.tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "commonjs",
"moduleResolution": "Node",
"sourceMap": true,
"outDir": "./dist-playwright"
},
"include": ["test/playwright"]
}
24 changes: 24 additions & 0 deletions sdk/identity/identity/rollup.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import resolve from "@rollup/plugin-node-resolve";
import cjs from "@rollup/plugin-commonjs";
import json from "@rollup/plugin-json";
import typescript from "@rollup/plugin-typescript";
import shim from "rollup-plugin-shim";

export default {
input: "./test/playwright/rollup/src/index.ts",
output: {
file: "test/playwright/rollup/dist/index.js",
format: "umd",
name: "main",
},
plugins: [
shim({}),
resolve({
preferBuiltins: false,
mainFields: ["module", "browser"],
}),
cjs(),
json(),
typescript(),
],
};
10 changes: 10 additions & 0 deletions sdk/identity/identity/test/playwright/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<!-- /index.js is a route defined in the Express.js server -->
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<!-- /index.js is a route defined in the Express.js server -->
<!-- /index.js is a route defined in the Express.js server -->
Suggested change
<!-- /index.js is a route defined in the Express.js server -->
<!-- /index.js is a route defined in the Express.js server -->

<script src="/index.js"></script>
</head>
<body>
</body>
</html>
21 changes: 21 additions & 0 deletions sdk/identity/identity/test/playwright/page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

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

export async function preparePage(page: Page): Promise<void> {
// Log and continue all network requests
await page.route("**", (route) => {
console.log("PLAYWRIGHT PAGE ROUTE:", route.request().url());
route.continue();
});

// Logging the page's console.logs
page.on("console", async (msg) => {
const values = [];
for (const arg of msg.args()) {
values.push(await arg.jsonValue());
}
console.log(...values);
});
}
95 changes: 95 additions & 0 deletions sdk/identity/identity/test/playwright/popup.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import * as dotenv from "dotenv";
import { test, expect } from "@playwright/test";
import { isLiveMode } from "@azure-tools/test-recorder";
import { prepareServer } from "./server";
import { preparePage } from "./page";

dotenv.config();

const clientId = process.env.AZURE_CLIENT_ID || process.env.AZURE_IDENTITY_BROWSER_CLIENT_ID;
const azureUsername = process.env.AZURE_USERNAME || process.env.AZURE_IDENTITY_TEST_USERNAME;
const azurePassword = process.env.AZURE_PASSWORD || process.env.AZURE_IDENTITY_TEST_PASSWORD;
const port = process.env.PORT || "8080";
const scope = "https://graph.microsoft.com/.default";

// The Azure Active Directory app registration should be of the type
// "spa" and the redirect endpoint should point to:
const homeUri = `http://localhost:${port}/`;

const credentialOptions = { redirectUri: homeUri };

test("Authenticates with a popup", async ({ page }) => {
test.skip(!isLiveMode(), "Playwright tests can only run on live mode");
test.skip(!clientId, "Client ID environment variable required");
test.skip(!azureUsername, "Username environment variable required");
test.skip(!azurePassword, "Password environment variable required");

const { start, stop } = await prepareServer({ port });
await preparePage(page);
await start();

// THE TEST BEGINS

// We go to the home page
await page.goto(homeUri);

const [popup] = await Promise.all([
page.waitForEvent("popup"),
page.evaluate(
async ({ clientId, scope, credentialOptions }) => {
const { InteractiveBrowserCredential } = (window as any).main;

const credential = new InteractiveBrowserCredential({
...credentialOptions,
clientId,
});

// The redirection to Azure happens here...
credential.getToken(scope);
},
{
clientId,
scope,
credentialOptions,
}
),
]);

// Interactive popup login with Playwright
await popup.waitForNavigation();
await popup.waitForSelector(`input[type="email"]`);
await popup.fill(`input[type="email"]`, azureUsername!);
await popup.waitForSelector(`input[type="submit"]`);
await popup.click(`input[type="submit"]`);
await popup.waitForLoadState("networkidle");
await popup.waitForSelector(`input[type="password"]`);
await popup.fill(`input[type="password"]`, azurePassword!);
await popup.waitForSelector(`input[type="submit"]`);
await popup.click(`input[type="submit"]`);
await popup.waitForSelector(`input[type="submit"]`);
await popup.click(`input[type="submit"]`);
await popup.waitForEvent("close");

const token = await page.evaluate(
async ({ clientId, scope, credentialOptions }) => {
const { InteractiveBrowserCredential } = (window as any).main;

const credential = new InteractiveBrowserCredential({
clientId,
...credentialOptions,
});

return await credential.getToken(scope);
},
{ clientId, scope, credentialOptions }
);

expect(token).toBeTruthy();
expect(token.token).toBeTruthy();
expect(token.expiresOnTimestamp).toBeTruthy();

await stop();
});
90 changes: 90 additions & 0 deletions sdk/identity/identity/test/playwright/redirect.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import * as dotenv from "dotenv";
import { test, expect } from "@playwright/test";
import { isLiveMode } from "@azure-tools/test-recorder";
import { prepareServer } from "./server";
import { preparePage } from "./page";

dotenv.config();

const clientId = process.env.AZURE_CLIENT_ID || process.env.AZURE_IDENTITY_BROWSER_CLIENT_ID;
const azureUsername = process.env.AZURE_USERNAME || process.env.AZURE_IDENTITY_TEST_USERNAME;
const azurePassword = process.env.AZURE_PASSWORD || process.env.AZURE_IDENTITY_TEST_PASSWORD;
const port = process.env.PORT || "8080";
const scope = "https://graph.microsoft.com/.default";

// The Azure Active Directory app registration should be of the type
// "spa" and the redirect endpoint should point to:
const homeUri = `http://localhost:${port}/`;

const credentialOptions = { redirectUri: homeUri };

test("Authenticates", async ({ page }) => {
test.skip(!isLiveMode(), "Playwright tests can only run on live mode");
test.skip(!clientId, "Client ID environment variable required");
test.skip(!azureUsername, "Username environment variable required");
test.skip(!azurePassword, "Password environment variable required");

const { start, stop } = await prepareServer({ port });
await preparePage(page);
await start();

// THE TEST BEGINS

// We go to the home page
await page.goto(homeUri);

await page.evaluate(
async ({ clientId, scope, credentialOptions }) => {
const { InteractiveBrowserCredential } = (window as any).main;

const credential = new InteractiveBrowserCredential({
...credentialOptions,
clientId,
loginStyle: "redirect",
});

// The redirection to Azure happens here...
credential.getToken(scope);
},
{ clientId, scope, credentialOptions }
);

// Interactive login with Playwright
await page.waitForNavigation();
await page.waitForSelector(`input[type="email"]`);
await page.fill(`input[type="email"]`, azureUsername!);
await page.waitForSelector(`input[type="submit"]`);
await page.click(`input[type="submit"]`);
await page.waitForLoadState("networkidle");
await page.waitForSelector(`input[type="password"]`);
await page.fill(`input[type="password"]`, azurePassword!);
await page.waitForSelector(`input[type="submit"]`);
await page.click(`input[type="submit"]`);
await page.waitForSelector(`input[type="submit"]`);
await page.click(`input[type="submit"]`);
await page.waitForURL(`${homeUri}**`);

const token = await page.evaluate(
async ({ clientId, scope, credentialOptions }) => {
const { InteractiveBrowserCredential } = (window as any).main;

const credential = new InteractiveBrowserCredential({
...credentialOptions,
clientId,
loginStyle: "redirect",
});

return await credential.getToken(scope);
},
{ clientId, scope, credentialOptions }
);

expect(token).toBeTruthy();
expect(token.token).toBeTruthy();
expect(token.expiresOnTimestamp).toBeTruthy();

await stop();
});
4 changes: 4 additions & 0 deletions sdk/identity/identity/test/playwright/rollup/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

export { InteractiveBrowserCredential } from "../../../../src/credentials/interactiveBrowserCredential.browser";
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I were to do this in another project, other than our SDK repo, I would load here the @azure/identity package, like:

export { InteractiveBrowserCredential } from "@azure/identity";

79 changes: 79 additions & 0 deletions sdk/identity/identity/test/playwright/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import * as express from "express";
import { readFileSync } from "fs";
import { Server } from "http";

// A simple web server that allows passing configuration and behavioral parameters.
// This file should be thought as the archetypicall representation of a web server,
// whereas the test file will include the nuance specific to testing the desired behavior.

/**
* Options to the server.
* With the intent to make the server parametrizable!
*/
export interface ServerOptions {
/**
* Port number as a string
*/
port: string;
}

/**
* Result of the prepareServer function.
*/
export interface PepareServerResult {
app: express.Application;
start: () => Promise<void>;
stop: () => Promise<void>;
}

/**
* Sets up a parametrizable Express server.
*/
export async function prepareServer(serverOptions: ServerOptions): Promise<PepareServerResult> {
const app = express();

/**
* Logging calls.
*/
app.use((req: express.Request, _res: express.Response, next: express.NextFunction) => {
console.log("Playwright Express test server:", req.url);
next();
});

/**
* Endpoint that loads the index.js
*/
app.get("/index.js", async (_req: express.Request, res: express.Response) => {
const indexContent = readFileSync("./test/playwright/rollup/dist/index.js", {
encoding: "utf8",
});
res.send(indexContent);
});

/**
* Home URI
*/
app.get("/", async (_req: express.Request, res: express.Response) => {
const indexContent = readFileSync("./test/playwright/index.html", { encoding: "utf8" });
res.send(indexContent);
});

let server: Server | undefined = undefined;

return {
app,
async start() {
server = app.listen(serverOptions.port, () => {
console.log(`Authorization code redirect server listening on port ${serverOptions.port}`);
});
},
async stop() {
if (server) {
server.close();
}
},
};
}
1 change: 1 addition & 0 deletions sdk/identity/identity/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ stages:
IDENTITY_SP_CERT_SNI: $(Agent.TempDirectory)/testsni.pfx
IDENTITY_SP_CERT_SNI_PEM: $(Agent.TempDirectory)/testsni.pem
IDENTITY_PEM_CONTENTS: $(net-identity-spcert-pem)
AZURE_IDENTITY_BROWSER_CLIENT_ID: $(identity-azure-sdk-js-test-browser-client-id)
Copy link
Contributor Author

@sadasant sadasant Apr 2, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to create an app registration with an “spa” redirect endpoint and store its client ID in the keyvault used for the CI en variables 🙂

2 changes: 1 addition & 1 deletion sdk/identity/identity/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@
}
},
"include": ["src/**/*", "test/**/*", "samples-dev/**/*.ts"],
"exclude": ["test/manual*/**/*", "node_modules"]
"exclude": ["test/manual*/**/*", "test/playwright/**/*", "node_modules"]
}