diff --git a/README.md b/README.md index c3d9ed4..a370d09 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,62 @@ Run tests using your normal Playwright command (for example): ```bash npm run test ``` + +## Helper overview + +This package exposes a small set of helpers that work together to drive a Canvas LTI launch in your deployment tests. + +### Environment and URL + +The required environment variables described above are: + +- `CANVAS_HOST` – base Canvas URL, e.g. `https://canvas.instructure.com` (or the corresponding beta/test host). +- `TEST_PATH` – path under Canvas for the page you want to exercise, e.g. `/accounts/1/external_tools/789`. +- `OAUTH_TOKEN` – bearer token used to obtain a Canvas login session for the tests. + +From these, the library builds a normalised test URL: + +```js +import { TEST_URL } from '@oxctl/deployment-test-utils' + +// TEST_URL === `${CANVAS_HOST}/${TEST_PATH}` +``` + +### Core helpers + +The main helpers are: + +- `TEST_URL` – normalised URL for the Canvas page under test. +- `login(request, page, host, token)` – perform an LTI login by exchanging the OAuth token for a session and navigating the Playwright `page` to it. This is typically called from the setup project rather than directly from individual tests. +- `grantAccessIfNeeded(page, context, toolUrl)` – visit the LTI tool and complete the grant-access flow if the tool requires it. This is also usually invoked from setup, not from the test body. +- `getLtiIFrame(page)` – return a `FrameLocator` for the LTI launch iframe. +- `waitForNoSpinners(frameLocator, initialDelay?)` – wait for `.view-spinner` elements inside the frame to disappear. + +### End-to-end example + +A typical deployment test might look like this: + +```js +import { test } from '@playwright/test' +import { + TEST_URL, + getLtiIFrame, + waitForNoSpinners +} from '@oxctl/deployment-test-utils' + +// Auth and LTI grant access are handled by the setup projects from this package. + +test('launches the tool via Canvas', async ({ page }) => { + await page.goto(TEST_URL) + + const frame = getLtiIFrame(page) + await waitForNoSpinners(frame) + + // Now interact with the tool inside the LTI iframe + await frame.getByText('XXXXXXXXXXXXXXX').click() +}) +``` + ## Auth storage state The setup project (`auth.setup.js`) will: diff --git a/package-lock.json b/package-lock.json index 5683fd9..7247b5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.1.3", "license": "MIT", "devDependencies": { - "@rollup/plugin-node-resolve": "^15.3.0", + "@types/node": "^24.10.1", + "typescript": "^5.9.3", "vite": "6.4.1" }, "peerDependencies": { @@ -475,54 +476,6 @@ "node": ">=18" } }, - "node_modules/@rollup/plugin-node-resolve": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", - "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "@types/resolve": "1.20.2", - "deepmerge": "^4.2.2", - "is-module": "^1.0.0", - "resolve": "^1.22.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.78.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.52.3", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.3.tgz", @@ -838,21 +791,15 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/resolve": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", - "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=0.10.0" + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" } }, "node_modules/dotenv": { @@ -910,13 +857,6 @@ "@esbuild/win32-x64": "0.25.10" } }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -949,52 +889,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", - "dev": true, - "license": "MIT" - }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1014,13 +908,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1034,6 +921,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1100,34 +988,12 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/rollup": { "version": "4.52.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz", "integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -1174,19 +1040,6 @@ "node": ">=0.10.0" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1204,6 +1057,27 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", diff --git a/package.json b/package.json index 1d69b58..c1c4691 100644 --- a/package.json +++ b/package.json @@ -11,25 +11,25 @@ "type": "module", "main": "./dist/testUtils.cjs", "module": "./dist/testUtils.js", - "types": "./dist/index.d.ts", + "types": "./dist/testUtils.d.ts", "exports": { ".": { + "types": "./dist/testUtils.d.ts", "import": "./dist/testUtils.js", "require": "./dist/testUtils.cjs" }, - "./testUtils": { - "import": "./dist/testUtils.js", - "require": "./dist/testUtils.cjs" - }, - "./config": "./src/config.js" + "./config": { + "import": "./src/config.js" + } }, "files": [ "dist/*", + "src/testUtils.js", "src/config.js", "src/setup/*" ], "scripts": { - "build": "vite build" + "build": "vite build && tsc -p tsconfig.types.json && cp src/testUtils.d.ts dist/testUtils.d.ts" }, "sideEffects": false, "peerDependencies": { @@ -42,7 +42,8 @@ } }, "devDependencies": { - "@rollup/plugin-node-resolve": "^15.3.0", + "@types/node": "^24.10.1", + "typescript": "^5.9.3", "vite": "6.4.1" } } diff --git a/src/testUtils.d.ts b/src/testUtils.d.ts new file mode 100644 index 0000000..8759a44 --- /dev/null +++ b/src/testUtils.d.ts @@ -0,0 +1,100 @@ +import {APIRequestContext, BrowserContext, FrameLocator, Locator, Page} from "@playwright/test"; + +/** + * Normalised test URL built from `CANVAS_HOST` and `TEST_PATH`. + * `src/setup/assertVariables.js` ensures these env vars exist and are normalised. + */ +export const TEST_URL: string; + +/** + * Perform an LTI login by requesting a session token and navigating the page + * to the returned session URL. + * + * @param request Playwright `APIRequestContext` used for HTTP requests. + * @param page Playwright page to navigate. + * @param host Canvas host (base URL). + * @param token OAuth bearer token. + */ +export function login( + request: APIRequestContext, + page: Page, + host: string, + token: string +): Promise; + +/** + * Visit `toolUrl` and complete the grant-access flow if the tool requests it. + * Navigates the page into the LTI tool, waits for loading to finish and + * resolves whether the tool requires an explicit grant. If required, the + * `grantAccess` helper is used to complete the flow. + * + * @param page Playwright page instance. + * @param context Playwright browser context. + * @param toolUrl URL of the LTI tool to visit. + */ +export function grantAccessIfNeeded( + page: Page, + context: BrowserContext, + toolUrl: string +): Promise; + +/** + * Return the frame locator for the LTI launch iframe. + * + * **Usage** + * + * ```js + * const ltiIFrame = getLtiIFrame(page) + * await ltiIFrame.getByText('Hello').click() + * ``` + * + * **Details** + * + * This helper finds the iframe injected by the LTI launch by querying for + * `iframe[data-lti-launch="true"]`. It’s a thin wrapper over + * `page.frameLocator('iframe[data-lti-launch="true"]')` for convenience and + * consistency in tests. + * + * @param page Playwright page. + * @returns Frame locator for the LTI launch iframe. + */ +export function getLtiIFrame(page: Page): FrameLocator; + +/** + * Take a screenshot of the provided locator and save it into the test + * output directory. Files are numbered sequentially for the duration of the + * process. + * + * @param locator Locator to screenshot. + * @param testInfo Playwright `testInfo`-like object; only `outputDir` is used. + */ +export function screenshot( + locator: Locator, + testInfo: { outputDir: string } +): Promise; + +/** + * Dismiss the Canvas beta warning banner if present on the current page. + * + * Looks for the "Close warning" button in the fixed bottom warning banner + * that appears on Canvas beta/test instances (for example, the banner + * described in the Canvas release schedule docs) and clicks it if it is + * visible. + * + * @param page Playwright page. + */ +export function dismissBetaBanner(page: Page): Promise; + +/** + * Wait for any `.view-spinner` elements inside the supplied frame locator to + * disappear. Optionally provide an initial delay before checking. The + * underlying assertion will time out after roughly 10 seconds if spinners + * remain. + * + * @param frameLocator Frame locator to query. + * @param initialDelay Milliseconds to wait before starting checks. + */ +export function waitForNoSpinners( + frameLocator: FrameLocator, + initialDelay?: number +): Promise; \ No newline at end of file diff --git a/src/testUtils.js b/src/testUtils.js index 75ac350..e823999 100644 --- a/src/testUtils.js +++ b/src/testUtils.js @@ -1,22 +1,7 @@ import { expect } from '@playwright/test' -/** - * Normalised test URL built from `CANVAS_HOST` and `TEST_PATH`. - * `src/setup/assertVariables.js` ensures these env vars exist and are normalised. - * @type {string} - */ export const TEST_URL = process.env.CANVAS_HOST + '/' + process.env.TEST_PATH - -/** - * Perform an LTI login by requesting a session token and navigating the page - * to the returned session URL. - * @param {import('@playwright/test').APIRequestContext} request - Playwright request context - * @param {import('@playwright/test').Page} page - Playwright page to navigate - * @param {string} host - Canvas host (base URL) - * @param {string} token - OAuth bearer token - * @returns {Promise} - */ export const login = async (request, page, host, token) => { await Promise.resolve( await request.get(`${host}/login/session_token`, { @@ -35,18 +20,6 @@ export const login = async (request, page, host, token) => { ) } - -/** - * Visit `toolUrl` and complete the grant-access flow if the tool requests it. - * Navigates the page into the LTI tool, waits for loading to finish and - * resolves whether the tool requires an explicit grant. If required, the - * `grantAccess` helper is used to complete the flow. - * - * @param {import('@playwright/test').Page} page - Playwright page instance - * @param {import('@playwright/test').BrowserContext} context - Playwright browser context - * @param {string} toolUrl - URL of the LTI tool to visit - * @returns {Promise} - */ export const grantAccessIfNeeded = async (page, context, toolUrl) => { await page.goto(toolUrl) const ltiToolFrame = getLtiIFrame(page) @@ -70,14 +43,6 @@ export const grantAccessIfNeeded = async (page, context, toolUrl) => { } } -/** - * Complete the grant access flow by clicking the authorise button in the - * popup page. Intended to be used by `grantAccessIfNeeded`. - * - * @param {import('@playwright/test').BrowserContext} context - Playwright browser context - * @param {import('@playwright/test').FrameLocator} frameLocator - Locator for the LTI frame - * @returns {Promise} - */ const grantAccess = async (context, frameLocator) => { const button = await frameLocator.getByRole('button') const [newPage] = await Promise.all([ @@ -91,36 +56,17 @@ const grantAccess = async (context, frameLocator) => { await close.click() } - -/** - * Return the frame locator for the LTI launch iframe. - * @param {import('@playwright/test').Page} page - Playwright page - * @returns {import('@playwright/test').FrameLocator} - */ export const getLtiIFrame = (page) => { return page.frameLocator('iframe[data-lti-launch="true"]') } let screenshotCount = 1 -/** - * Take a screenshot of the provided locator and save it into the test - * output directory. Files are numbered sequentially for the duration of the - * process. - * - * @param {import('@playwright/test').Locator} locator - Locator to screenshot - * @param {{ outputDir: string }} testInfo - Playwright `testInfo` object (only `outputDir` used) - * @returns {Promise} - */ + export const screenshot = async (locator, testInfo) => { await locator.screenshot({ path: `${testInfo.outputDir}/${screenshotCount}.png`, fullPage: true }) screenshotCount++ } -/** - * Dismiss the beta warning banner if present on the current page. - * @param {import('@playwright/test').Page} page - Playwright page - * @returns {Promise} - */ export const dismissBetaBanner = async (page) => { if (page.url().includes('beta')) { const banner = page.getByRole('button', { name: 'Close warning' }) @@ -130,14 +76,6 @@ export const dismissBetaBanner = async (page) => { } } -/** - * Wait for any `.view-spinner` elements inside the supplied frame locator to - * disappear. Optionally provide an initial delay before checking. - * - * @param {import('@playwright/test').FrameLocator} frameLocator - Frame locator to query - * @param {number} [initialDelay=1000] - milliseconds to wait before starting checks - * @returns {Promise} - */ export const waitForNoSpinners = async (frameLocator, initialDelay = 1000) => { await new Promise(r => setTimeout(r, initialDelay)) await expect(frameLocator.locator('.view-spinner')).toHaveCount(0, { timeout: 10000 }) diff --git a/tsconfig.types.json b/tsconfig.types.json new file mode 100644 index 0000000..850149b --- /dev/null +++ b/tsconfig.types.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "allowJs": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "module": "ESNext", + "target": "ES2022", + "moduleResolution": "Node", + "lib": ["ESNext"], + "types": ["node"], + "outDir": "./dist", + "skipLibCheck": true + }, + "include": [ + "src/testUtils.js", + "src/config.js" + ] +} \ No newline at end of file