diff --git a/.github/workflows/build-notebooks-TEMPLATE.yaml b/.github/workflows/build-notebooks-TEMPLATE.yaml index 67f36cce7..f68dd4479 100644 --- a/.github/workflows/build-notebooks-TEMPLATE.yaml +++ b/.github/workflows/build-notebooks-TEMPLATE.yaml @@ -134,7 +134,7 @@ jobs: if: "${{ fromJson(inputs.github).event_name == 'pull_request' }}" env: IMAGE_TAG: "${{ github.sha }}" - IMAGE_REGISTRY: "localhost:5000/workbench-images" + IMAGE_REGISTRY: "ghcr.io/${{ github.repository }}/workbench-images" CONTAINER_BUILD_CACHE_ARGS: "--cache-from ${{ env.CACHE }}" # We don't have access to image registry, so disable pushing PUSH_IMAGES: "no" @@ -155,7 +155,7 @@ jobs: TARGET="$FS_SCAN_FOLDER" TYPE="fs" else - TARGET="localhost:5000/workbench-images:${{ inputs.target }}-${{ github.sha }}" + TARGET="ghcr.io/${{ github.repository }}/workbench-images:${{ inputs.target }}-${{ github.sha }}" TYPE="image" fi elif [[ "$EVENT_NAME" == "schedule" ]]; then @@ -215,5 +215,50 @@ jobs: cat $REPORT_FOLDER/$REPORT_FILE >> $GITHUB_STEP_SUMMARY + # https://playwright.dev/docs/ci + # https://playwright.dev/docs/docker + # we leave little free disk space after we mount LVM for podman storage + # not enough to install playwright; running playwright in podman uses the space we have + - name: Run Playwright tests + if: ${{ fromJson(inputs.github).event_name == 'pull_request' && contains(inputs.target, 'codeserver') }} + # --ipc=host because Microsoft says so in Playwright docs + # --net=host because testcontainers connects to the Reaper container's exposed port + # we need to pass through the relevant environment variables + # DEBUG configures nodejs debuggers, sets different verbosity as needed + # CI=true is set on every CI nowadays + # PODMAN_SOCK should be mounted to /var/run/docker.sock, other likely mounting locations may not exist (mkdir -p) + # TEST_TARGET is the workbench image the test will run + # --volume(s) let us access docker socket and not clobber host's node_modules + run: | + podman run \ + --interactive --rm \ + --ipc=host \ + --net=host \ + --env "CI=true" \ + --env "NPM_CONFIG_fund=false" \ + --env "DEBUG=testcontainers:*" \ + --env "PODMAN_SOCK=/var/run/docker.sock" \ + --env "TEST_TARGET" \ + --volume ${PODMAN_SOCK}:/var/run/docker.sock \ + --volume ${PWD}:/mnt \ + --volume /mnt/node_modules \ + mcr.microsoft.com/playwright:v1.48.1-noble \ + /bin/bash <({ + connectCDP: [false, {option: true}], + codeServerSource: [{url:'http://localhost:8787'}, {option: true}], + page: async ({ page, connectCDP }, use) => { + if (!connectCDP) { + await use(page) + } else { + // we close the provided page and send onwards our own + await page.close() + { + const browser = await chromium.connectOverCDP(`http://localhost:${connectCDP}`); + const defaultContext = browser.contexts()[0]; + const page = defaultContext.pages()[0]; + await use(page) + } + } + }, + codeServer: [async ({ page, codeServerSource }, use) => { + if (codeServerSource?.url) { + await use(new CodeServer(page, codeServerSource.url)) + } else { + const image = codeServerSource.image ?? (() => { + throw new Error("invalid config: codeserver image not specified") + })() + const container = await new GenericContainer(image) + .withExposedPorts(8787) + .withWaitStrategy(new HttpWaitStrategy('/', 8787, {abortOnContainerExit: true})) + .start(); + await use(new CodeServer(page, `http://${container.getHost()}:${container.getMappedPort(8787)}`)) + await container.stop() + } + }, {timeout: 10 * 60 * 1000}], +}); + +test.beforeAll(setupTestcontainers) + +test('open codeserver', async ({codeServer, page}) => { + await page.goto(codeServer.url) + + await codeServer.isEditorVisible() +}) + +test('wait for welcome screen to load', async ({codeServer, page}, testInfo) => { + await page.goto(codeServer.url); + + await codeServer.isEditorVisible() + page.on("console", console.log) + + await codeServer.isEditorVisible() + await utils.waitForStableDOM(page, "div.monaco-workbench", 1000, 10000) + await utils.waitForNextRender(page) + + await utils.takeScreenshot(page, testInfo, "welcome.png") +}) + +test('use the terminal to run command', async ({codeServer, page}, testInfo) => { + await page.goto(codeServer.url); + + await test.step("Should always see the code-server editor", async () => { + expect(await codeServer.isEditorVisible()).toBe(true) + }) + + await test.step("should show the Integrated Terminal", async () => { + await codeServer.focusTerminal() + expect(await page.isVisible("#terminal")).toBe(true) + }) + + await test.step("should execute Terminal command successfully", async () => { + await page.keyboard.type('echo The answer is $(( 6 * 7 )). > answer.txt', {delay: 100}) + await page.keyboard.press('Enter', {delay: 100}) + }) + + await test.step("should open the file", async() => { + const file = path.join('/opt/app-root/src', 'answer.txt') + await codeServer.openFile(file) + await expect(page.getByText("The answer is 42.")).toBeVisible() + }) + +}) diff --git a/tests/browser/tests/models/codeserver.ts b/tests/browser/tests/models/codeserver.ts new file mode 100644 index 000000000..82185ab27 --- /dev/null +++ b/tests/browser/tests/models/codeserver.ts @@ -0,0 +1,225 @@ +// Copyright (c) 2019 Coder Technologies Inc. +// https://github.com/coder/code-server/blob/main/test/e2e/models/CodeServer.ts + +import {Page} from "@playwright/test"; +import * as path from "node:path"; + +/** + * Class for managing code-server. + */ +export class CodeServer { + private readonly logger = { + debug: (x) => console.log(x), + named: (name) => this.logger, + } + + constructor(public readonly page: Page, public readonly url: string) { + } + + /** + * Checks if the editor is visible + */ + async isEditorVisible(): Promise { + let editorSelector = "div.monaco-workbench" + + await this.page.waitForSelector(editorSelector, {timeout: 10000}) // this waits for initial load, let's wait longer + const visible = await this.page.isVisible(editorSelector) + + this.logger.debug(`Editor is ${visible ? "not visible" : "visible"}!`) + + return visible + } + + /** + * Wait for a tab to open for the specified file. + */ + async waitForTab(file: string): Promise { + await this.page.waitForSelector(`.tab :text("${path.basename(file)}")`) + } + + /** + * Focuses the integrated terminal by navigating through the command palette. + * + * This should focus the terminal no matter if it already has focus and/or is + * or isn't visible already. It will always create a new terminal to avoid + * clobbering parallel tests. + */ + async focusTerminal() { + const doFocus = async (): Promise => { + await this.executeCommandViaMenus("Terminal: Create New Terminal") + try { + await this.page.waitForLoadState("load") + await this.page.waitForSelector("textarea.xterm-helper-textarea:focus-within", { timeout: 5000 }) + return true + } catch (error) { + return false + } + } + + let attempts = 1 + while (!(await doFocus())) { + ++attempts + this.logger.debug(`no focused terminal textarea, retrying (${attempts}/∞)`) + } + + this.logger.debug(`opening terminal took ${attempts} ${plural(attempts, "attempt")}`) + } + + /** + * Open a file by using menus. + */ + async openFile(file: string) { + await this.navigateMenus(["File", "Open File..."]) + await this.navigateQuickInput([path.basename(file)]) + await this.waitForTab(file) + } + + /** + * Navigate to the command palette via menus then execute a command by typing + * it then clicking the match from the results. + */ + async executeCommandViaMenus(command: string) { + await this.navigateMenus(["View", "Command Palette..."]) + + await this.page.keyboard.type(command) + + await this.page.hover(`text=${command}`) + await this.page.click(`text=${command}`) + } + + /** + * Navigate through the menu, retrying on failure. + */ + async navigateMenus(menus: string[]): Promise { + await this.navigateItems(menus, '[aria-label="Application Menu"]', async (selector) => { + await this.page.click(selector) + }) + } + + /** + * Navigate through a currently opened "quick input" widget, retrying on + * failure. + */ + async navigateQuickInput(items: string[]): Promise { + await this.navigateItems(items, ".quick-input-widget") + } + + /** + * Navigate through the items in the selector. `open` is a function that will + * open the menu/popup containing the items through which to navigation. + */ + async navigateItems(items: string[], selector: string, open?: (selector: string) => void): Promise { + const logger = this.logger.named(selector) + + /** + * If the selector loses focus or gets removed this will resolve with false, + * signaling we need to try again. + */ + const openThenWaitClose = async (ctx: Context) => { + if (open) { + await open(selector) + } + this.logger.debug(`watching ${selector}`) + try { + await this.page.waitForSelector(`${selector}:not(:focus-within)`) + } catch (error) { + if (!ctx.finished()) { + this.logger.debug(`${selector} navigation: ${(error as any).message || error}`) + } + } + return false + } + + /** + * This will step through each item, aborting and returning false if + * canceled or if any navigation step has an error which signals we need to + * try again. + */ + const navigate = async (ctx: Context) => { + const steps: Array<{ fn: () => Promise; name: string }> = [ + { + fn: () => this.page.waitForSelector(`${selector}:focus-within`), + name: "focus", + }, + ] + + for (const item of items) { + // Normally these will wait for the item to be visible and then execute + // the action. The problem is that if the menu closes these will still + // be waiting and continue to execute once the menu is visible again, + // potentially conflicting with the new set of navigations (for example + // if the old promise clicks logout before the new one can). By + // splitting them into two steps each we can cancel before running the + // action. + steps.push({ + fn: () => this.page.hover(`${selector} :text-is("${item}")`, { trial: true }), + name: `${item}:hover:trial`, + }) + steps.push({ + fn: () => this.page.hover(`${selector} :text-is("${item}")`, { force: true }), + name: `${item}:hover:force`, + }) + steps.push({ + fn: () => this.page.click(`${selector} :text-is("${item}")`, { trial: true }), + name: `${item}:click:trial`, + }) + steps.push({ + fn: () => this.page.click(`${selector} :text-is("${item}")`, { force: true }), + name: `${item}:click:force`, + }) + } + + for (const step of steps) { + try { + logger.debug(`navigation step: ${step.name}`) + await step.fn() + if (ctx.canceled()) { + logger.debug("navigation canceled") + return false + } + } catch (error) { + logger.debug(`navigation: ${(error as any).message || error}`) + return false + } + } + return true + } + + // We are seeing the menu closing after opening if we open it too soon and + // the picker getting recreated in the middle of trying to select an item. + // To counter this we will keep trying to navigate through the items every + // time we lose focus or there is an error. + let attempts = 1 + let context = new Context() + while (!(await Promise.race([openThenWaitClose(context), navigate(context)]))) { + ++attempts + logger.debug(`closed, retrying (${attempts}/∞)`) + context.cancel() + context = new Context() + } + + context.finish() + logger.debug(`navigation took ${attempts} ${plural(attempts, "attempt")}`) + } +} + +function plural(count: number, singular: string) { + return `${count} ${singular}s` +} + +class Context { + private _canceled = false + private _done = false + public canceled(): boolean { + return this._canceled + } + public finished(): boolean { + return this._done + } + public cancel(): void { + this._canceled = true + } + public finish(): void { + this._done = true + } +} diff --git a/tests/browser/tests/testcontainers.ts b/tests/browser/tests/testcontainers.ts new file mode 100644 index 000000000..b92541d62 --- /dev/null +++ b/tests/browser/tests/testcontainers.ts @@ -0,0 +1,34 @@ +//import {spawnSync} from "node:child_process"; +import * as process from "node:process"; + +var testcontainersSetup: boolean = false; + +export function setupTestcontainers() { + if (testcontainersSetup) {return} + testcontainersSetup = true; + + // https://github.com/testcontainers/testcontainers-node/blob/main/docs/configuration.md + // https://node.testcontainers.org/supported-container-runtimes/ + process.env['DEBUG'] = 'testcontainers'; + + switch (process.platform) { + case "linux": { + if ('PODMAN_SOCK' in process.env) { process.env['DOCKER_HOST'] = process.env['PODMAN_SOCK'] } + else { + let XDG_RUNTIME_DIR = process.env['XDG_RUNTIME_DIR'] || `/var/run/user/${process.env['UID']}` + process.env['DOCKER_HOST'] = `unix://${XDG_RUNTIME_DIR}/podman/podman.sock`; + } + process.env['TESTCONTAINERS_RYUK_DISABLED'] = 'false'; + process.env['TESTCONTAINERS_RYUK_PRIVILEGED'] = 'true'; + break + } + case "darwin": { + // let result = spawnSync('podman', ['machine', 'inspect', '--format={{.ConnectionInfo.PodmanSocket.Path}}']); + // let dockerHost = result.stdout.toString().trimEnd() + // process.env['DOCKER_HOST'] = `unix://${dockerHost}`; + process.env['TESTCONTAINERS_RYUK_DISABLED'] = 'false'; + process.env['TESTCONTAINERS_RYUK_PRIVILEGED'] = 'true'; + break + } + } +} \ No newline at end of file diff --git a/tests/browser/tests/utils.ts b/tests/browser/tests/utils.ts new file mode 100644 index 000000000..4e93c671c --- /dev/null +++ b/tests/browser/tests/utils.ts @@ -0,0 +1,83 @@ +import {Page} from "@playwright/test"; + +export async function waitForStableDOM(page: Page, pageRootSelector: string, checkPeriod: number, timeout: number): Promise { + // https://github.com/cypress-io/cypress/issues/5275#issuecomment-1003669708 + const parameters: [string, number, number] = [pageRootSelector, checkPeriod, timeout] + await page.evaluate( ([pageRootSelector, checkPeriod, timeout]) => { + const targetNode = document.querySelector(pageRootSelector); + const config = { attributes: true, childList: true, subtree: true }; + + var started = performance.now(); + var mutated = started; + + const callback: MutationCallback = (mutationList, _observer) => { + for (const mutation of mutationList) { + mutated = performance.now() + + if (mutation.type === "childList") { + console.log("A child node has been added or removed."); + } else if (mutation.type === "attributes") { + console.log(`The ${mutation.attributeName} attribute was modified.`); + } + } + }; + + const observer = new MutationObserver(callback); + observer.observe(targetNode, config); + + return new Promise((resolve, reject) => { + let loop = () => { + let now = performance.now(); + if (now - mutated > checkPeriod) { + observer.disconnect(); + resolve(); + } + if (now - started > timeout) { + observer.disconnect(); + reject(); + } + window.setInterval(loop, checkPeriod); + }; + loop() + }) + }, parameters); +} + +export async function waitForNextRender(page: Page) { + /** + * https://webperf.tips/tip/react-hook-paint/ + * + * alternative implementation, send yourself a message + * + * function runAfterFramePaint(callback) { + * requestAnimationFrame(() => { + * const messageChannel = new MessageChannel(); + * + * messageChannel.port1.onmessage = callback; + * messageChannel.port2.postMessage(undefined); + * }); + * } + */ + // wait for next frame being rendered + await page.evaluate( () => { + return new window.Promise((callback: Function) => { + window.requestAnimationFrame(() => window.setTimeout(callback)); + }); + }); +} + +// https://github.com/microsoft/playwright/issues/14854#issuecomment-1155347129 +export async function screenshotOnFailure({ page }, testInfo) { + if (testInfo.status !== testInfo.expectedStatus) { + await takeScreenshot(page, testInfo, `failure.png`) + } +} + +export async function takeScreenshot( page , testInfo, filename) { + // Get a unique place for the screenshot. + const screenshotPath = testInfo.outputPath(filename); + // Add it to the report. + testInfo.attachments.push({name: 'screenshot', path: screenshotPath, contentType: 'image/png'}); + // Take the screenshot itself. + await page.screenshot({path: screenshotPath, timeout: 5000}); +}