Skip to content
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
28 changes: 23 additions & 5 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand All @@ -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 },
},
}
13 changes: 13 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -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'
21 changes: 19 additions & 2 deletions src/setup/assertVariables.js
Original file line number Diff line number Diff line change
@@ -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()
Expand Down
40 changes: 36 additions & 4 deletions src/setup/auth.setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
*/
Comment on lines +15 to +19
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

The JSDoc comment states "Raw environment values used for constructing the test URL and auth" and includes @type {string}, but this comment block applies to multiple variable declarations (lines 20, 22, 24) with different types. Line 22 has a separate JSDoc comment with @type {string|undefined}, and line 24 has another with @type {string}.

This creates ambiguity. Consider either:

  1. Having one JSDoc comment per variable declaration, or
  2. Clarifying that this is a general description for the following section of related variables
Suggested change
/**
* Raw environment values used for constructing the test URL and auth.
* These are intentionally read as strings and normalized below.
* @type {string}
*/
// Raw environment values used for constructing the test URL and auth.
// These are intentionally read as strings and normalized below.

Copilot uses AI. Check for mistakes.
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<void>}
*/
setup('authenticate', async ({ context, page }) => {
await fs.mkdir(path.dirname(authFile), { recursive: true })

Expand Down
137 changes: 71 additions & 66 deletions src/testUtils.js
Original file line number Diff line number Diff line change
@@ -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`.
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

The JSDoc documentation states that host is an "Optional host to use instead of TEST_URL" with a default value of TEST_URL. However, line 25 throws an error if host is falsy. This is inconsistent because:

  1. If TEST_URL is empty (when CANVAS_HOST is not set), the function will throw
  2. The documentation implies the parameter is truly optional, but the implementation requires it to be truthy

The documentation should clarify that while the parameter has a default value, the host must be non-empty, or the implementation should handle empty hosts more gracefully.

Suggested change
* @param {string} [host] - Optional host to use instead of `TEST_URL`.
* @param {string} [host] - Optional host to use instead of `TEST_URL`. Must be a non-empty string. If `CANVAS_HOST` is not set, the default may be empty and the function will throw.

Copilot uses AI. Check for mistakes.
* @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)
}
Comment on lines +24 to 31
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

The login function signature has been changed in a breaking way. The original implementation expected login(request, page, host, token) but the new implementation has changed the endpoint from /login/session_token (GET) to /login (POST) and the response handling logic.

Looking at src/setup/auth.setup.js line 53, the function is called as login(page.request, page, host, token), but the new implementation:

  1. Changes the HTTP method from GET to POST
  2. Changes the endpoint from /login/session_token to /login
  3. Removes the session URL navigation logic
  4. Only navigates to the host instead of the session URL

This breaks the existing authentication flow. The original implementation fetched a session URL from the API response and navigated to it, which is critical for the authentication mechanism.

Copilot uses AI. Check for mistakes.

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)
Comment on lines +38 to +41
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

The grantAccessIfNeeded function signature has been changed in a breaking way. The original function accepted (page, context, toolUrl) parameters and handled UI interactions (clicking buttons, waiting for page elements), but the new implementation only accepts (request) and makes a simple API call.

Looking at src/setup/auth.setup.js line 54, it's called as grantAccessIfNeeded(page, context, \${host}/${url}`)`. The new implementation:

  1. Removes the page and context parameters
  2. Removes the UI interaction logic (waiting for "Please Grant Access" text, clicking buttons)
  3. Replaces it with a simple POST to /grant-access

This completely changes the behavior and breaks the existing usage.

Suggested change
export const grantAccessIfNeeded = async (request) => {
if (!process.env.CANVAS_HOST || !process.env.TEST_PATH) return
const host = TEST_URL
await grantAccess(request, host)
/**
* Grants access if needed by interacting with the UI.
* Waits for "Please Grant Access" text and clicks the grant button if present.
* @param {import('@playwright/test').Page} page - Playwright `page` instance.
* @param {object} context - Playwright `context` instance.
* @param {string} toolUrl - The tool URL to check for access.
*/
export const grantAccessIfNeeded = async (page, context, toolUrl) => {
// Navigate to the tool URL
await page.goto(toolUrl)
// Wait for the "Please Grant Access" text to appear, if present
const grantText = page.locator('text=Please Grant Access')
if (await grantText.count()) {
// Click the "Grant Access" button
const grantButton = page.locator('button:has-text("Grant Access")')
await expect(grantButton).toBeVisible({ timeout: 5000 })
await grantButton.click()
// Optionally, wait for navigation or confirmation
await page.waitForLoadState('networkidle')
}

Copilot uses AI. Check for mistakes.
}

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<import('@playwright/test').ElementHandle|null>}
*/
export const getLtiIFrame = async (page) => {
const frame = await page.frameLocator('iframe[name="tool_frame"]')
return frame ? frame.elementHandle() : null
Comment on lines +54 to +60
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

The getLtiIFrame function has breaking changes:

  1. The original function returned a FrameLocator (synchronously) with page.frameLocator('iframe[data-lti-launch="true"]')
  2. The new function is async and returns a Promise<ElementHandle|null> with a different selector iframe[name="tool_frame"]
  3. The return type changed from FrameLocator to ElementHandle|null

The original function was used to get a frame locator for further interactions (like ltiToolFrame.getByText() in the original grantAccessIfNeeded). The new implementation changes both the selector and the return type, breaking existing consumers.

Suggested change
* Get the LTI iframe element handle from the page.
* @param {import('@playwright/test').Page} page - Playwright `page` instance.
* @returns {Promise<import('@playwright/test').ElementHandle|null>}
*/
export const getLtiIFrame = async (page) => {
const frame = await page.frameLocator('iframe[name="tool_frame"]')
return frame ? frame.elementHandle() : null
* Get the LTI iframe frame locator from the page.
* @param {import('@playwright/test').Page} page - Playwright `page` instance.
* @returns {import('@playwright/test').FrameLocator}
*/
export const getLtiIFrame = (page) => {
return page.frameLocator('iframe[data-lti-launch="true"]')

Copilot uses AI. Check for mistakes.
Comment on lines +59 to +60
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

The implementation has a logical error: frameLocator() returns a FrameLocator object (not a promise), but the code attempts to call elementHandle() on it. FrameLocator doesn't have an elementHandle() method. This will cause a runtime error.

Additionally, the ternary check frame ? frame.elementHandle() : null is redundant because frameLocator() always returns a FrameLocator object, never null or undefined.

Suggested change
const frame = await page.frameLocator('iframe[name="tool_frame"]')
return frame ? frame.elementHandle() : null
const frame = page.frame({ name: "tool_frame" });
return frame ? await frame.frameElement() : null;

Copilot uses AI. Check for mistakes.
}

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 })
Comment on lines +64 to +69
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

The screenshot function signature has been changed in a breaking way. The original function accepted (locator, testInfo) and used testInfo.outputDir with an auto-incrementing counter to generate file paths. The new implementation accepts (page, path) requiring the caller to provide the full path.

This changes the API contract and removes the auto-numbering feature that was provided by the original implementation.

Suggested change
* 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 })
* Take a screenshot of the given locator and save it to a numbered file in the test output directory.
* @param {import('@playwright/test').Locator} locator - Playwright locator to screenshot.
* @param {import('@playwright/test').TestInfo} testInfo - Playwright testInfo object.
*/
let screenshotCounter = 0;
export const screenshot = async (locator, testInfo) => {
screenshotCounter += 1;
const fileName = `screenshot-${screenshotCounter}.png`;
const filePath = require('path').join(testInfo.outputDir, fileName);
await locator.screenshot({ path: filePath });

Copilot uses AI. Check for mistakes.
}

/**
* 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()
Comment on lines +77 to +79
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

The dismissBetaBanner function implementation has breaking changes:

  1. The original checked if the URL contains 'beta' before attempting to dismiss
  2. The original used getByRole('button', { name: 'Close warning' }) selector
  3. The new implementation uses #beta-banner and button[aria-label="Close"] selectors without the URL check

These selector changes may cause the function to fail if the actual beta banner uses different selectors than assumed.

Suggested change
const banner = page.locator('#beta-banner')
if (await banner.count()) {
await banner.locator('button[aria-label="Close"]').click()
const url = page.url();
if (!url.includes('beta')) return;
const closeButton = page.getByRole('button', { name: 'Close warning' });
if (await closeButton.count()) {
await closeButton.click();

Copilot uses AI. Check for mistakes.
}
}

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 })
Comment on lines +87 to +88
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

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

The waitForNoSpinners function signature and implementation have breaking changes:

  1. The original accepted (frameLocator, initialDelay = 1000) with a 1-second initial delay and looked for .view-spinner within a frame
  2. The new implementation accepts only (page) with no initial delay and looks for .spinner on the page

This removes the initial delay feature, changes the selector from .view-spinner to .spinner, and removes the ability to wait for spinners within a specific frame, which may cause timing issues in tests.

Suggested change
export const waitForNoSpinners = async (page) => {
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 5000 })
/**
* Wait for all .view-spinner elements to disappear from the given frame.
* @param {import('@playwright/test').FrameLocator|import('@playwright/test').Page} frameLocator - Playwright frameLocator or page instance.
* @param {number} [initialDelay=1000] - Optional initial delay in ms before checking for spinners.
*/
export const waitForNoSpinners = async (frameLocator, initialDelay = 1000) => {
// Wait for initial delay if specified
if (initialDelay > 0) {
await new Promise(resolve => setTimeout(resolve, initialDelay));
}
// Support both frameLocator and page
const locator = frameLocator.locator
? frameLocator.locator('.view-spinner')
: frameLocator.locator('.view-spinner');
await expect(locator).toHaveCount(0, { timeout: 5000 });

Copilot uses AI. Check for mistakes.
}
9 changes: 9 additions & 0 deletions vite.config.js
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down