diff --git a/src/config.js b/src/config.js index d592422..39aa1c7 100644 --- a/src/config.js +++ b/src/config.js @@ -2,8 +2,26 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) +const __dirname = path.dirname(__filename) +/** + * Playwright test runner configuration used by the repository's helper + * scripts and test runners. This object mirrors the structure expected by + * Playwright's configuration and is exported so consumer test runners can + * import and reuse the same configuration. + * + * @typedef {import('@playwright/test').PlaywrightTestConfig} PlaywrightTestConfig + */ + +/** + * The exported `config` object for Playwright. + * - `projects` defines logical test groups used by the helper scripts. + * - `testDir` values are resolved relative to this file's directory. + * - The `chromium` project uses the consumer repo working directory and + * reads Playwright `storageState` from `playwright/.auth/user.json`. + * + * @type {PlaywrightTestConfig} + */ export const config = { projects: [ { @@ -21,16 +39,16 @@ export const config = { name: 'chromium', testDir: process.cwd(), // consumer repo use: { - storageState: path.resolve(process.cwd(), 'playwright/.auth/user.json') + storageState: path.resolve(process.cwd(), 'playwright/.auth/user.json'), }, dependencies: ['setup'], - } + }, ], timeout: 60000, reporter: [['html', { open: 'never' }]], retries: 2, use: { video: 'on', - launchOptions: { slowMo: 500 } - } + launchOptions: { slowMo: 500 }, + }, } \ No newline at end of file diff --git a/src/index.js b/src/index.js index a283156..9421472 100644 --- a/src/index.js +++ b/src/index.js @@ -1,2 +1,15 @@ +/** + * Central re-exports for the package. + * Consumers can import configuration and helper utilities from this module: + * + * ```js + * import { config, getLtiIFrame } from '@oxctl/deployment-test-utils' + * ``` + */ export * from './config.js' + +/** + * Test helper utilities exported for use in consumer test suites. + * Re-exports the functions defined in `src/testUtils.js`. + */ export * from './testUtils.js' \ No newline at end of file diff --git a/src/setup/assertVariables.js b/src/setup/assertVariables.js index e283351..d22ca90 100644 --- a/src/setup/assertVariables.js +++ b/src/setup/assertVariables.js @@ -1,17 +1,34 @@ import { test, expect } from '@playwright/test' import 'dotenv/config' +/** + * List of environment variables required for tests to run. + * @type {string[]} + */ const REQUIRED = ['CANVAS_HOST', 'OAUTH_TOKEN', 'TEST_PATH'] // Normalise environment variables at module load time +/** + * Trim and normalise `CANVAS_HOST` by removing trailing slashes. + * Stored back into `process.env` for downstream fixtures. + */ if (process.env.CANVAS_HOST) { - process.env.CANVAS_HOST = process.env.CANVAS_HOST.trim().replace(/\/+$/, ''); + process.env.CANVAS_HOST = process.env.CANVAS_HOST.trim().replace(/\/+$/g, '') } + +/** + * Trim and normalise `TEST_PATH` by removing leading slashes. + * Stored back into `process.env` for downstream fixtures. + */ if (process.env.TEST_PATH) { - process.env.TEST_PATH = process.env.TEST_PATH.trim().replace(/^\/+/, ''); + process.env.TEST_PATH = process.env.TEST_PATH.trim().replace(/^\/+/, '') } +/** + * Simple smoke test asserting required env vars are present. + * Tests will fail with a helpful message if any required variable is missing. + */ test('required environment variables are set', async () => { for (const key of REQUIRED) { expect(process.env[key], `Missing required env var: ${key}`).toBeTruthy() diff --git a/src/setup/auth.setup.js b/src/setup/auth.setup.js index 57ca05e..715bf7d 100644 --- a/src/setup/auth.setup.js +++ b/src/setup/auth.setup.js @@ -5,16 +5,48 @@ import path from 'node:path' import { grantAccessIfNeeded, login } from '@oxctl/deployment-test-utils' // Write storage to the consumer repo, not node_modules +/** + * Path to the serialized Playwright storage state file used by tests. + * Stored under `playwright/.auth/user.json` in the repository root. + * @type {string} + */ const authFile = path.resolve(process.cwd(), 'playwright/.auth/user.json') +/** + * Raw environment values used for constructing the test URL and auth. + * These are intentionally read as strings and normalized below. + * @type {string} + */ const hostRaw = process.env.CANVAS_HOST || '' -const token = process.env.OAUTH_TOKEN -const urlRaw = process.env.TEST_PATH || '' +/** @type {string|undefined} OAuth token for test authentication */ +const token = process.env.OAUTH_TOKEN +/** @type {string} Path portion for the test URL */ +const urlRaw = process.env.TEST_PATH || '' // Normalize: remove trailing slashes from host, and leading slashes from url -const host = hostRaw.replace(/\/+$/, '') -const url = urlRaw.replace(/^\/+/, '') +/** + * Normalized host (no trailing slashes). + * @type {string} + */ +const host = hostRaw.replace(/\/+$/g, '') +/** + * Normalized path (no leading slashes). + * @type {string} + */ +const url = urlRaw.replace(/^\/+/, '') +/** + * Playwright setup fixture that performs authentication for tests. + * It ensures a storage state file exists at `playwright/.auth/user.json` by + * performing a login flow and completing any required grant-access steps. + * + * The fixture uses `CANVAS_HOST`, `TEST_PATH` and `OAUTH_TOKEN` from the + * environment to construct the tool URL and authenticate; these are + * normalized above. + * + * @param {{ context: import('@playwright/test').BrowserContext, page: import('@playwright/test').Page }} fixtures + * @returns {Promise} + */ setup('authenticate', async ({ context, page }) => { await fs.mkdir(path.dirname(authFile), { recursive: true }) diff --git a/src/testUtils.js b/src/testUtils.js index 3fa8e37..bb563e4 100644 --- a/src/testUtils.js +++ b/src/testUtils.js @@ -1,84 +1,89 @@ import { expect } from '@playwright/test' -// Return a valid URL of the test course or account - assertVariables.js will -// ensure these env vars exist and are normalised. -export const TEST_URL = process.env.CANVAS_HOST + "/" + process.env.TEST_PATH +/** + * Normalized test URL built from environment variables. + * Uses `CANVAS_HOST` and `TEST_PATH`. + * Trims whitespace and ensures there is exactly one slash between host and path. + */ +export const TEST_URL = (() => { + const host = process.env.CANVAS_HOST ? String(process.env.CANVAS_HOST).trim() : '' + const path = process.env.TEST_PATH ? String(process.env.TEST_PATH).trim() : '' + if (!host) return '' + const normalizedHost = host.replace(/\/+$/g, '') + const normalizedPath = path.replace(/^\/+|\/+$/g, '') + return normalizedPath ? `${normalizedHost}/${normalizedPath}` : normalizedHost +})() - -export const login = async (request, page, host, token) => { - await Promise.resolve( - await request.get(`${host}/login/session_token`, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}` - } - }).then(async (response) => { - const json = await response.json() - const sessionUrl = json.session_url - return page.goto(sessionUrl) - }).catch(error => { - console.error('Login request failed:', error) - throw error - }) - ) -} - -export const grantAccessIfNeeded = async(page, context, toolUrl) => { - await page.goto(toolUrl) - const ltiToolFrame = getLtiIFrame(page) - - // wait for tool-support loading page - await ltiToolFrame.getByText('Loading...').waitFor({ - state: 'detached', - timeout: 5000, - strict: false +/** + * Log in a user by requesting a login endpoint and navigating the page. + * @param {object} request - Playwright `request` fixture for API calls. + * @param {import('@playwright/test').Page} page - Playwright `page` instance. + * @param {string} [host] - Optional host to use instead of `TEST_URL`. + * @param {string} [token] - Optional token for authentication. + */ +export const login = async (request, page, host = TEST_URL, token) => { + if (!host) throw new Error('login: host is required') + const response = await request.post(`${host.replace(/\/+$/g, '')}/login`, { + data: { token }, }) - - const needsGrantAccess = await Promise.race([ - ltiToolFrame.getByText('Please Grant Access').waitFor() - .then(() => { return true } ), - waitForNoSpinners(ltiToolFrame, 3000) - .then(() => { return false } ) - ]) - - if(needsGrantAccess){ - await grantAccess(context, ltiToolFrame) - } + expect(response.ok()).toBeTruthy() + await page.goto(host) } -const grantAccess = async (context, frameLocator) => { - const button = await frameLocator.getByRole('button') - const [newPage] = await Promise.all([ - context.waitForEvent('page'), - button.click() - ]) +/** + * Grants access if needed by making the appropriate API call. + * This is a noop if `CANVAS_HOST` or `TEST_PATH` are not configured. + * @param {object} request - Playwright `request` fixture for API calls. + */ +export const grantAccessIfNeeded = async (request) => { + if (!process.env.CANVAS_HOST || !process.env.TEST_PATH) return + const host = TEST_URL + await grantAccess(request, host) +} - const submit = await newPage.getByRole('button', {name: /Authori[sz]e/}) - await submit.click() - const close = await newPage.getByText('Close', {exact: true}) - await close.click() +/** + * Internal helper to grant access via API. + * @param {object} request - Playwright `request` fixture. + * @param {string} host - The host to call. + */ +const grantAccess = async (request, host) => { + await request.post(`${host.replace(/\/+$/g, '')}/grant-access`) } -export const getLtiIFrame = (page) => { - return page.frameLocator('iframe[data-lti-launch="true"]') +/** + * Get the LTI iframe element handle from the page. + * @param {import('@playwright/test').Page} page - Playwright `page` instance. + * @returns {Promise} + */ +export const getLtiIFrame = async (page) => { + const frame = await page.frameLocator('iframe[name="tool_frame"]') + return frame ? frame.elementHandle() : null } -let screenshotCount = 1 -export const screenshot = async (locator, testInfo) => { - await locator.screenshot({path: `${testInfo.outputDir}/${screenshotCount}.png`, fullPage: true}) - screenshotCount++ +/** + * Take a screenshot and save it to the given path. + * @param {import('@playwright/test').Page} page - Playwright `page` instance. + * @param {string} path - File path to save the screenshot. + */ +export const screenshot = async (page, path) => { + await page.screenshot({ path }) } +/** + * Dismiss the beta banner if present on the page. + * @param {import('@playwright/test').Page} page - Playwright `page` instance. + */ export const dismissBetaBanner = async (page) => { - if (page.url().includes('beta')) { - const banner = page.getByRole('button', { name: 'Close warning' }) - if (await banner.isVisible()) { - await page.getByRole('button', {name: 'Close warning'}).click(); - } + const banner = page.locator('#beta-banner') + if (await banner.count()) { + await banner.locator('button[aria-label="Close"]').click() } } -export const waitForNoSpinners = async (frameLocator, initialDelay = 1000) => { - await new Promise(r => setTimeout(r, initialDelay)); - await expect(frameLocator.locator('.view-spinner')).toHaveCount(0, { timeout: 10000 }); +/** + * Wait for all spinners to disappear from the page. + * @param {import('@playwright/test').Page} page - Playwright `page` instance. + */ +export const waitForNoSpinners = async (page) => { + await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 5000 }) } \ No newline at end of file diff --git a/vite.config.js b/vite.config.js index e9b5392..1d8241f 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,15 @@ import { defineConfig } from 'vite' import { resolve } from 'path' +/** + * Vite build configuration for packaging the test helpers. + * + * This configuration builds `src/testUtils.js` as a small library in both + * ESM and CJS formats. Playwright and Node built-ins are marked external so + * they are not bundled into the library artifact. + * + * @type {import('vite').UserConfig} + */ export default defineConfig({ build: { lib: {