diff --git a/.azure-pipelines/publish-docker.yml b/.azure-pipelines/publish-docker.yml index 4c089b2aa6293..605544d3bc885 100644 --- a/.azure-pipelines/publish-docker.yml +++ b/.azure-pipelines/publish-docker.yml @@ -50,7 +50,7 @@ extends: - task: UseNode@1 inputs: - version: '22.x' + version: '26.x' displayName: "Install Node.js" # Relocate the Docker data-root to the large /mnt volume: this job builds @@ -67,13 +67,23 @@ extends: echo '{ "data-root": "/mnt/docker" }' | sudo tee /etc/docker/daemon.json sudo service docker start - # Register QEMU/binfmt handlers so `docker build --platform linux/arm64` - # works on the amd64 host. Equivalent to docker/setup-qemu-action@v4. - task: Bash@3 - displayName: "Set up QEMU for arm64 builds" + displayName: "setup .npmrc" inputs: targetType: "inline" - script: docker run --privileged --rm tonistiigi/binfmt --install arm64 + script: | + echo "registry=https://devdiv.pkgs.visualstudio.com/DevDiv/_packaging/DevDiv_PublicPackages/npm/registry/" >> .npmrc + echo "registry=https://devdiv.pkgs.visualstudio.com/DevDiv/_packaging/DevDiv_PublicPackages/npm/registry/" >> tests/playwright-test/stable-test-runner/.npmrc + + - task: npmAuthenticate@0 + displayName: "authenticate the private npm registry" + inputs: + workingFile: .npmrc + + - task: npmAuthenticate@0 + displayName: "authenticate the private npm registry for stable-test-runner" + inputs: + workingFile: tests/playwright-test/stable-test-runner/.npmrc - script: npm ci displayName: "npm ci" @@ -89,8 +99,20 @@ extends: scriptLocation: "inlineScript" inlineScript: "az acr login --name playwright" + - task: Bash@3 + displayName: "Register QEMU (binfmt) for arm64 cross-build" + inputs: + targetType: "inline" + script: "docker run --rm --privileged ${ACR_CACHE_PREFIX}tonistiigi/binfmt --install arm64" + env: + ACR_CACHE_PREFIX: "playwright.azurecr.io/cached/" + - task: Bash@3 displayName: "Build & publish Docker images" inputs: targetType: "inline" script: "./utils/docker/publish_docker.sh ${{ parameters.releaseChannel }}" + env: + ACR_CACHE_PREFIX: "playwright.azurecr.io/cached/" + UBUNTU_MIRROR_PREFIX: "azure." + NPMRC_SECRET: "$(Build.SourcesDirectory)/.npmrc" diff --git a/.github/workflows/publish_release_docker.yml b/.github/workflows/publish_release_docker.yml deleted file mode 100644 index 6c88db2af48eb..0000000000000 --- a/.github/workflows/publish_release_docker.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: "publish release - Docker" - -on: - workflow_dispatch: - release: - types: [published] - -jobs: - publish-docker-release: - name: "publish to DockerHub" - runs-on: ubuntu-22.04 - permissions: - id-token: write # This is required for OIDC login (azure/login) to succeed - contents: read # This is required for actions/checkout to succeed - if: github.repository == 'microsoft/playwright' - environment: allow-publishing-docker-to-acr - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: lts/* - registry-url: 'https://registry.npmjs.org' - - name: Set up Docker QEMU for arm64 docker builds - uses: docker/setup-qemu-action@v4 - with: - platforms: arm64 - - run: npm ci - - run: npm run build - - name: Azure Login - uses: azure/login@v3 - with: - client-id: ${{ secrets.AZURE_DOCKER_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_DOCKER_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_DOCKER_SUBSCRIPTION_ID }} - - name: Login to ACR via OIDC - run: az acr login --name playwright - - run: ./utils/docker/publish_docker.sh stable diff --git a/.github/workflows/tests_bidi.yml b/.github/workflows/tests_bidi.yml index 1bda2bacf9f6f..b11400d438fb4 100644 --- a/.github/workflows/tests_bidi.yml +++ b/.github/workflows/tests_bidi.yml @@ -55,6 +55,8 @@ jobs: - run: npx playwright install --with-deps chromium - if: matrix.channel == 'moz-firefox-nightly' run: | + sudo apt-get update + sudo apt-get install -y libavcodec60 echo "BIDI_FFPATH=$(npx -y @puppeteer/browsers install firefox@nightly | tail -n1 | sed 's/^[^ ]* *//')" >> "$GITHUB_ENV" - name: Run tests run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run biditest -- --retries=${{ matrix.isPullRequest && 2 || 0 }} --project=${{ matrix.channel }}* diff --git a/packages/injected/src/bidiInsertText.ts b/packages/injected/src/bidiInsertText.ts new file mode 100644 index 0000000000000..ba6769e840f18 --- /dev/null +++ b/packages/injected/src/bidiInsertText.ts @@ -0,0 +1,74 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { InjectedScript } from './injectedScript'; + +function bidiInsertText(window: Window, text: string): Element | undefined { + let element = window.document.activeElement; + while (element?.shadowRoot) + element = element.shadowRoot.activeElement; + if (!element) + return; + const elementType = element.nodeName.toLocaleLowerCase(); + if (elementType === 'iframe' || elementType === 'frame') { + // The focused element lives inside a nested frame. Hand the frame element + // back to the caller so it can recurse into that frame. + return element; + } else if (elementType === 'input' || elementType === 'textarea') { + const inputElement = element as HTMLInputElement | HTMLTextAreaElement; + const start = inputElement.selectionStart; + if (start === null) { + inputElement.value += text; + } else { + let value = inputElement.value; + value = value.substring(0, start) + text + value.substring(inputElement.selectionEnd!); + inputElement.value = value; + const caretPosition = start + text.length; + inputElement.setSelectionRange(caretPosition, caretPosition); + } + inputElement.dispatchEvent(new InputEvent('input', { data: text, bubbles: true, composed: true })); + } else if (element instanceof HTMLElement && element.isContentEditable) { + const selection = window.getSelection()!; + let range; + if (selection.rangeCount) + range = selection.getRangeAt(0); + if (!range || !element.contains(range.commonAncestorContainer)) { + range = window.document.createRange(); + range.selectNodeContents(element); + range.collapse(true); + } + range.deleteContents(); + const lines = text.split('\n'); + for (let i = lines.length - 1; i >= 0; i--) { + range.insertNode(window.document.createTextNode(lines[i])); + if (i > 0) + range.insertNode(window.document.createElement('br')); + } + range.collapse(); + selection.removeAllRanges(); + selection.addRange(range); + element.dispatchEvent(new InputEvent('input', { data: text, bubbles: true, composed: true })); + } +} + +export class BidiInsertTextInstaller { + constructor(injectedScript: InjectedScript) { + const window = injectedScript.window; + (window as any).__pw_bidiInsertText = (text: string) => bidiInsertText(window, text); + } +} + +export default BidiInsertTextInstaller; diff --git a/packages/playwright-core/src/server/bidi/DEPS.list b/packages/playwright-core/src/server/bidi/DEPS.list index ea0bea7e1ae50..4e9f90d1462b6 100644 --- a/packages/playwright-core/src/server/bidi/DEPS.list +++ b/packages/playwright-core/src/server/bidi/DEPS.list @@ -2,6 +2,7 @@ @utils/** @isomorphic/** ../ +../../generated/ ./third_party/ [bidiOverCdp.ts] diff --git a/packages/playwright-core/src/server/bidi/bidiBrowser.ts b/packages/playwright-core/src/server/bidi/bidiBrowser.ts index 236bbec39af9a..2e7d646455832 100644 --- a/packages/playwright-core/src/server/bidi/bidiBrowser.ts +++ b/packages/playwright-core/src/server/bidi/bidiBrowser.ts @@ -23,6 +23,7 @@ import { bidiBytesValueToString } from './bidiNetworkManager'; import { BidiPage, kPlaywrightBindingChannel } from './bidiPage'; import { PageBinding } from '../page'; import * as bidi from './third_party/bidiProtocol'; +import * as rawBidiInsertTextSource from '../../generated/bidiInsertTextSource'; import type { RegisteredListener } from '@utils/eventsHelper'; import type { BrowserOptions } from '../browser'; @@ -226,6 +227,7 @@ export class BidiBrowserContext extends BrowserContext { const promises: Promise[] = [ super.initialize(), ]; + promises.push(this.extendInjectedScript(rawBidiInsertTextSource.source)); const downloadBehavior: bidi.Browser.DownloadBehavior = this._options.acceptDownloads === 'accept' ? { type: 'allowed', destinationFolder: this._browser.options.downloadsPath } : { type: 'denied' }; diff --git a/packages/playwright-core/src/server/bidi/bidiInput.ts b/packages/playwright-core/src/server/bidi/bidiInput.ts index 30a6439745197..0dbfc917c7832 100644 --- a/packages/playwright-core/src/server/bidi/bidiInput.ts +++ b/packages/playwright-core/src/server/bidi/bidiInput.ts @@ -18,20 +18,19 @@ import { resolveSmartModifierString } from '../input'; import { getBidiKeyValue } from './third_party/bidiKeyboard'; import * as bidi from './third_party/bidiProtocol'; +import type { Frame } from '../frames'; import type * as input from '../input'; import type * as types from '../types'; import type { BidiSession } from './bidiConnection'; +import type { BidiPage } from './bidiPage'; import type { Progress } from '../progress'; +import type { FrameExecutionContext } from '../dom'; export class RawKeyboardImpl implements input.RawKeyboard { - private _session: BidiSession; + private _page: BidiPage; - constructor(session: BidiSession) { - this._session = session; - } - - setSession(session: BidiSession) { - this._session = session; + constructor(page: BidiPage) { + this._page = page; } async keydown(progress: Progress, modifiers: Set, keyName: string, description: input.KeyDescription, autoRepeat: boolean): Promise { @@ -49,18 +48,28 @@ export class RawKeyboardImpl implements input.RawKeyboard { } async sendText(progress: Progress, text: string): Promise { - const actions: bidi.Input.KeySourceAction[] = []; - for (const char of text) { - const value = getBidiKeyValue(char); - actions.push({ type: 'keyDown', value }); - actions.push({ type: 'keyUp', value }); + let frame: Frame | null = this._page._page.mainFrame(); + while (frame) { + const context: FrameExecutionContext = await progress.race(frame.mainContext()); + const handle = await progress.race(context.evaluateHandle((text: string) => (window as any).__pw_bidiInsertText(text), text)); + // insertText returns the focused frame element when the focus lives inside a nested frame, + // otherwise the text was inserted (if possible) and we are done. + const element = handle.asElement(); + if (!element) { + handle.dispose(); + return; + } + try { + frame = await progress.race(element.contentFrame(progress)); + } finally { + element.dispose(); + } } - await this._performActions(progress, actions); } private async _performActions(progress: Progress, actions: bidi.Input.KeySourceAction[]) { - await progress.race(this._session.send('input.performActions', { - context: this._session.sessionId, + await progress.race(this._page._session.send('input.performActions', { + context: this._page._session.sessionId, actions: [ { type: 'key', diff --git a/packages/playwright-core/src/server/bidi/bidiPage.ts b/packages/playwright-core/src/server/bidi/bidiPage.ts index 28fb957ec7e30..9915a93604067 100644 --- a/packages/playwright-core/src/server/bidi/bidiPage.ts +++ b/packages/playwright-core/src/server/bidi/bidiPage.ts @@ -63,7 +63,7 @@ export class BidiPage implements PageDelegate { constructor(browserContext: BidiBrowserContext, bidiSession: BidiSession, opener: BidiPage | null) { this._session = bidiSession; this._opener = opener; - this.rawKeyboard = new RawKeyboardImpl(bidiSession); + this.rawKeyboard = new RawKeyboardImpl(this); this.rawMouse = new RawMouseImpl(bidiSession); this.rawTouchscreen = new RawTouchscreenImpl(bidiSession); this._contextIdToContext = new Map(); diff --git a/packages/playwright-core/src/server/chromium/crBrowser.ts b/packages/playwright-core/src/server/chromium/crBrowser.ts index 6a2b2a512ccea..e4d95a68432d2 100644 --- a/packages/playwright-core/src/server/chromium/crBrowser.ts +++ b/packages/playwright-core/src/server/chromium/crBrowser.ts @@ -370,7 +370,7 @@ export class CRBrowserContext extends BrowserContext { } override async doCreateNewPage(): Promise { - const { targetId } = await this._browser._session.send('Target.createTarget', { url: 'about:blank', browserContextId: this._browserContextId, background: false, focus: false }); + const { targetId } = await this._browser._session.send('Target.createTarget', { url: 'about:blank', browserContextId: this._browserContextId }); return this._browser._crPages.get(targetId)!._page; } diff --git a/packages/playwright-core/src/server/electron/electron.ts b/packages/playwright-core/src/server/electron/electron.ts index 89485c74f33b9..7e3d3b01b15bf 100644 --- a/packages/playwright-core/src/server/electron/electron.ts +++ b/packages/playwright-core/src/server/electron/electron.ts @@ -25,7 +25,7 @@ import { eventsHelper } from '@utils/eventsHelper'; import { envArrayToObject, launchProcess } from '@utils/processLauncher'; import { ManualPromise } from '@isomorphic/manualPromise'; import { libPath } from '../../package'; -import { validateBrowserContextOptions } from '../browserContext'; +import { BrowserContext, validateBrowserContextOptions } from '../browserContext'; import { CRBrowser } from '../chromium/crBrowser'; import { CRConnection } from '../chromium/crConnection'; import { createHandle, CRExecutionContext } from '../chromium/crExecutionContext'; @@ -38,7 +38,6 @@ import { WebSocketTransport } from '../transport'; import { nullProgress } from '../progress'; import type { BrowserOptions, BrowserProcess } from '../browser'; -import type { BrowserContext } from '../browserContext'; import type { CRBrowserContext } from '../chromium/crBrowser'; import type { CRSession } from '../chromium/crConnection'; import type { CRPage } from '../chromium/crPage'; @@ -86,6 +85,7 @@ export class ElectronApplication extends SdkObject { this._nodeElectronHandlePromise.resolve(new js.JSHandle(this._nodeExecutionContext!, 'object', 'ElectronModule', remoteObject.objectId!)); }); this._nodeSession.on('Runtime.consoleAPICalled', event => this._onConsoleAPI(event)); + this._browserContext.on(BrowserContext.Events.Page, page => this._onPage(page)); const appClosePromise = new Promise(f => this.once(ElectronApplication.Events.Close, f)); this._browserContext.setCustomCloseHandler(async () => { const electronHandle = await this._nodeElectronHandlePromise; @@ -138,6 +138,28 @@ export class ElectronApplication extends SdkObject { await this._browserContext.close(progress, { reason: 'Application exited' }); } + private _onPage(page: Page) { + if (process.env.PLAYWRIGHT_ELECTRON_LEGACY_PAGE_CLOSE) + return; + // Target.closeTarget can hang on Electron when the close races a committing + // navigation. Close from the main process instead. We close the webContents + // rather than the BrowserWindow because BrowserWindow.close() always runs the + // beforeunload handler, while webContents.close() lets us opt in or out of it. + page.setCustomCloseHandler(async runBeforeUnload => { + const electronHandle = await this._nodeElectronHandlePromise; + const closed = await electronHandle.evaluate(({ webContents }, { targetId, runBeforeUnload }) => { + const wc = webContents.fromDevToolsTargetId(targetId); + if (!wc || wc.isDestroyed()) + return false; + wc.close({ waitForBeforeUnload: runBeforeUnload }); + return true; + }, { targetId: (page.delegate as CRPage)._targetId, runBeforeUnload }).catch(() => false); + // Fall back to the default close if the webContents could not be found. + if (!closed) + await page.delegate.closePage(runBeforeUnload); + }); + } + async browserWindow(progress: Progress, page: Page): Promise> { // Assume CRPage as Electron is always Chromium. const targetId = (page.delegate as CRPage)._targetId; diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 1a42d6e4e9e4a..70c512667dcad 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -202,6 +202,7 @@ export class Page extends SdkObject { readonly overlay: Overlay; readonly screencast: Screencast; _closeReason: string | undefined; + private _customCloseHandler?: (runBeforeUnload: boolean) => Promise; constructor(delegate: PageDelegate, browserContext: BrowserContext) { super(browserContext, 'page'); @@ -831,11 +832,16 @@ export class Page extends SdkObject { this._lifecycle = 'closing'; // This might throw if the browser context containing the page closes // while we are trying to close the page. - await this.delegate.closePage(false).catch(e => debugLogger.log('error', e)); + const closePage = this._customCloseHandler ?? (runBeforeUnload => this.delegate.closePage(runBeforeUnload)); + await closePage(false).catch(e => debugLogger.log('error', e)); } await this.closedPromise; } + setCustomCloseHandler(handler: ((runBeforeUnload: boolean) => Promise) | undefined) { + this._customCloseHandler = handler; + } + async runBeforeUnload(progress: Progress) { await progress.race(this._runBeforeUnload()); } @@ -843,7 +849,8 @@ export class Page extends SdkObject { private async _runBeforeUnload() { // This might throw if the browser context containing the page closes // while we are trying to close the page. - await this.delegate.closePage(true).catch(e => debugLogger.log('error', e)); + const closePage = this._customCloseHandler ?? (runBeforeUnload => this.delegate.closePage(runBeforeUnload)); + await closePage(true).catch(e => debugLogger.log('error', e)); } isClosed(): boolean { diff --git a/packages/playwright/src/transform/compilationCache.ts b/packages/playwright/src/transform/compilationCache.ts index bd5648c305c0b..6c71d39f98d50 100644 --- a/packages/playwright/src/transform/compilationCache.ts +++ b/packages/playwright/src/transform/compilationCache.ts @@ -101,6 +101,27 @@ function _innerAddToCompilationCacheAndSerialize(filename: string, entry: Memory }; } +// Cached code files are prefixed with a `// ` line so that a partially +// written cache entry is detected and ignored when reading. +function writeCodeCache(codePath: string, code: string) { + fs.writeFileSync(codePath, `// ${calculateSha1(code)}\n${code}`, 'utf8'); +} + +function readCodeCache(codePath: string): string { + const content = fs.readFileSync(codePath, 'utf8'); + const newLineIndex = content.indexOf('\n'); + if (newLineIndex === -1) + throw new Error(`Cache file is missing the hash header`); + const firstLine = content.substring(0, newLineIndex); + const sha1Length = 40; + if (firstLine.length !== '// '.length + sha1Length || !firstLine.startsWith('// ')) + throw new Error(`Cache file has a malformed hash header`); + const code = content.substring(newLineIndex + 1); + if (calculateSha1(code) !== firstLine.substring('// '.length)) + throw new Error(`Cache file content does not match the hash header`); + return code; +} + type CompilationCacheLookupResult = { serializedCache?: any; cachedCode?: string; @@ -113,7 +134,7 @@ export function getFromCompilationCache(filename: string, contentHash: string, m const cache = memoryCache.get(filename); if (cache?.codePath) { try { - return { cachedCode: fs.readFileSync(cache.codePath, 'utf-8') }; + return { cachedCode: readCodeCache(cache.codePath) }; } catch { // Not able to read the file - fall through. } @@ -128,7 +149,7 @@ export function getFromCompilationCache(filename: string, contentHash: string, m const sourceMapPath = cachePath + '.map'; const dataPath = cachePath + '.data'; try { - const cachedCode = fs.readFileSync(codePath, 'utf8'); + const cachedCode = readCodeCache(codePath); const serializedCache = _innerAddToCompilationCacheAndSerialize(filename, { codePath, sourceMapPath, dataPath, moduleUrl }); return { cachedCode, serializedCache }; } catch { @@ -145,7 +166,7 @@ export function getFromCompilationCache(filename: string, contentHash: string, m fs.writeFileSync(sourceMapPath, JSON.stringify(map), 'utf8'); if (data.size) fs.writeFileSync(dataPath, JSON.stringify(Object.fromEntries(data.entries()), undefined, 2), 'utf8'); - fs.writeFileSync(codePath, code, 'utf8'); + writeCodeCache(codePath, code); const serializedCache = _innerAddToCompilationCacheAndSerialize(filename, { codePath, sourceMapPath, dataPath, moduleUrl }); return { serializedCache }; } diff --git a/tests/bidi/expectations/moz-firefox-nightly-library.txt b/tests/bidi/expectations/moz-firefox-nightly-library.txt index e1fab0a7e29c1..e13c5d5e1441c 100644 --- a/tests/bidi/expectations/moz-firefox-nightly-library.txt +++ b/tests/bidi/expectations/moz-firefox-nightly-library.txt @@ -63,7 +63,6 @@ library/browsertype-connect.spec.ts › run-server › should save download [fai library/browsertype-connect.spec.ts › run-server › should upload a folder [fail] library/browsertype-launch.spec.ts › should reject if launched browser fails immediately [fail] library/capabilities.spec.ts › SharedArrayBuffer should work @smoke [timeout] -library/capabilities.spec.ts › should play video @smoke [timeout] library/channels.spec.ts › should work with the domain module [timeout] library/chromium/chromium.spec.ts › PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 › serviceWorker(), and fromServiceWorker() work [timeout] library/chromium/chromium.spec.ts › PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 › setExtraHTTPHeaders [timeout] diff --git a/tests/bidi/expectations/moz-firefox-nightly-page.txt b/tests/bidi/expectations/moz-firefox-nightly-page.txt index 1841a75ac3c2d..d8cf3f1ca04e8 100644 --- a/tests/bidi/expectations/moz-firefox-nightly-page.txt +++ b/tests/bidi/expectations/moz-firefox-nightly-page.txt @@ -101,9 +101,7 @@ page/page-goto.spec.ts › should send referer of cross-origin URL [fail] page/page-history.spec.ts › goBack/goForward should work with bfcache-able pages [timeout] page/page-history.spec.ts › page.goBack should work for file urls [timeout] page/page-history.spec.ts › regression test for issue 20791 [flaky] -page/page-keyboard.spec.ts › insertText should only emit input event [fail] page/page-keyboard.spec.ts › should press audio and media control keys [fail] -page/page-keyboard.spec.ts › should send a character with insertText [fail] page/page-mouse.spec.ts › should always round down [fail] page/page-mouse.spec.ts › should dblclick the div [timeout] page/page-network-idle.spec.ts › should not wait for an open EventSource connection [timeout] diff --git a/tests/library/defaultbrowsercontext-2.spec.ts b/tests/library/defaultbrowsercontext-2.spec.ts index e8a9f5b7a40f5..823ad6492769a 100644 --- a/tests/library/defaultbrowsercontext-2.spec.ts +++ b/tests/library/defaultbrowsercontext-2.spec.ts @@ -283,6 +283,27 @@ it('dialog.accept should work', { await context.close(); }); +it('CacheStorage entry should survive page.reload()', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/41618' } +}, async ({ launchPersistent, server }) => { + const { context, page } = await launchPersistent(); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(async () => { + const cache = await caches.open('repro-cache'); + await cache.put('/meta', new Response('payload')); + }); + + await page.reload(); + + const after = await page.evaluate(async () => { + const cache = await caches.open('repro-cache'); + const resp = await cache.match('/meta'); + return resp ? await resp.text() : null; + }); + expect(after).toBe('payload'); + await context.close(); +}); + it('exposes browser', async ({ launchPersistent }) => { const { context } = await launchPersistent(); const browser = context.browser(); diff --git a/tests/page/page-cache-storage.spec.ts b/tests/page/page-cache-storage.spec.ts new file mode 100644 index 0000000000000..4087533895baa --- /dev/null +++ b/tests/page/page-cache-storage.spec.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './pageTest'; + +test('CacheStorage entry should survive page.reload()', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/41618' } +}, async ({ page, server, browserName }) => { + test.fail(browserName === 'webkit', 'Ephemeral CacheStorage is not persisted across reload in WebKit, consistent with Safari'); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(async () => { + const cache = await caches.open('repro-cache'); + await cache.put('/meta', new Response('payload')); + }); + + await page.reload(); + + const after = await page.evaluate(async () => { + const cache = await caches.open('repro-cache'); + const resp = await cache.match('/meta'); + return resp ? await resp.text() : null; + }); + expect(after).toBe('payload'); +}); diff --git a/tests/page/page-fill.spec.ts b/tests/page/page-fill.spec.ts index a59f8cd12e55a..5aca6d1229e5f 100644 --- a/tests/page/page-fill.spec.ts +++ b/tests/page/page-fill.spec.ts @@ -224,8 +224,8 @@ it('should not double-fill in contenteditable with beforeinput handler in Firefo type: 'issue', description: 'https://github.com/microsoft/playwright/issues/36715' } -}, async ({ page, browserName }) => { - it.fixme(browserName === 'firefox', 'https://github.com/microsoft/playwright/issues/36715'); +}, async ({ page, browserName, isBidi }) => { + it.fixme(browserName === 'firefox' && !isBidi, 'https://github.com/microsoft/playwright/issues/36715'); await page.setContent(`
diff --git a/utils/docker/Dockerfile.jammy b/utils/docker/Dockerfile.jammy index 20a96869529d4..16ad0fb7f0ecb 100644 --- a/utils/docker/Dockerfile.jammy +++ b/utils/docker/Dockerfile.jammy @@ -1,16 +1,21 @@ -FROM ubuntu:jammy +ARG ACR_CACHE_PREFIX +FROM ${ACR_CACHE_PREFIX}ubuntu:jammy ARG DEBIAN_FRONTEND=noninteractive ARG TZ=America/Los_Angeles ARG DOCKER_IMAGE_NAME_TEMPLATE="mcr.microsoft.com/playwright:v%version%-jammy" ARG NODE_VERSION=24 # autogenerated via ./update-playwright-node.mjs +ARG UBUNTU_MIRROR_PREFIX ENV LANG=C.UTF-8 ENV LC_ALL=C.UTF-8 # === INSTALL Node.js === -RUN apt-get update && \ +RUN --mount=type=secret,id=npmrc,target=/root/.npmrc,required=false \ + find /etc/apt -type f \( -name '*.list' -o -name '*.sources' \) \ + -exec sed -i "s|http://archive.ubuntu.com|http://${UBUNTU_MIRROR_PREFIX}archive.ubuntu.com|g; s|http://ports.ubuntu.com|http://${UBUNTU_MIRROR_PREFIX}ports.ubuntu.com|g" {} + && \ + apt-get update && \ apt-get install -y curl wget gpg ca-certificates && \ mkdir -p /etc/apt/keyrings && \ curl -sL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \ diff --git a/utils/docker/Dockerfile.noble b/utils/docker/Dockerfile.noble index 175bd21ca3f13..938014b23fe08 100644 --- a/utils/docker/Dockerfile.noble +++ b/utils/docker/Dockerfile.noble @@ -1,16 +1,21 @@ -FROM ubuntu:noble +ARG ACR_CACHE_PREFIX +FROM ${ACR_CACHE_PREFIX}ubuntu:noble ARG DEBIAN_FRONTEND=noninteractive ARG TZ=America/Los_Angeles ARG DOCKER_IMAGE_NAME_TEMPLATE="mcr.microsoft.com/playwright:v%version%-noble" ARG NODE_VERSION=24 # autogenerated via ./update-playwright-node.mjs +ARG UBUNTU_MIRROR_PREFIX ENV LANG=C.UTF-8 ENV LC_ALL=C.UTF-8 # === INSTALL Node.js === -RUN apt-get update && \ +RUN --mount=type=secret,id=npmrc,target=/root/.npmrc,required=false \ + find /etc/apt -type f \( -name '*.list' -o -name '*.sources' \) \ + -exec sed -i "s|http://archive.ubuntu.com|http://${UBUNTU_MIRROR_PREFIX}archive.ubuntu.com|g; s|http://ports.ubuntu.com|http://${UBUNTU_MIRROR_PREFIX}ports.ubuntu.com|g" {} + && \ + apt-get update && \ apt-get install -y curl wget gpg ca-certificates && \ mkdir -p /etc/apt/keyrings && \ curl -sL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \ diff --git a/utils/docker/Dockerfile.resolute b/utils/docker/Dockerfile.resolute index 4900a6fa67f60..14df37e9853c3 100644 --- a/utils/docker/Dockerfile.resolute +++ b/utils/docker/Dockerfile.resolute @@ -1,16 +1,21 @@ -FROM ubuntu:resolute +ARG ACR_CACHE_PREFIX +FROM ${ACR_CACHE_PREFIX}ubuntu:resolute ARG DEBIAN_FRONTEND=noninteractive ARG TZ=America/Los_Angeles ARG DOCKER_IMAGE_NAME_TEMPLATE="mcr.microsoft.com/playwright:v%version%-resolute" ARG NODE_VERSION=24 # autogenerated via ./update-playwright-node.mjs +ARG UBUNTU_MIRROR_PREFIX ENV LANG=C.UTF-8 ENV LC_ALL=C.UTF-8 # === INSTALL Node.js === -RUN apt-get update && \ +RUN --mount=type=secret,id=npmrc,target=/root/.npmrc,required=false \ + find /etc/apt -type f \( -name '*.list' -o -name '*.sources' \) \ + -exec sed -i "s|http://archive.ubuntu.com|http://${UBUNTU_MIRROR_PREFIX}archive.ubuntu.com|g; s|http://ports.ubuntu.com|http://${UBUNTU_MIRROR_PREFIX}ports.ubuntu.com|g" {} + && \ + apt-get update && \ # Install Node.js apt-get install -y curl wget gpg ca-certificates && \ mkdir -p /etc/apt/keyrings && \ diff --git a/utils/docker/build.sh b/utils/docker/build.sh index 506950ed8e99c..ccbb1e61c55af 100755 --- a/utils/docker/build.sh +++ b/utils/docker/build.sh @@ -37,4 +37,16 @@ else exit 1 fi -docker build --platform "${PLATFORM}" -t "$3" -f "Dockerfile.$2" . +SECRET_ARGS=() +if [[ -n "${NPMRC_SECRET:-}" ]]; then + SECRET_ARGS+=(--secret "id=npmrc,src=${NPMRC_SECRET}") +fi + +# Keep each arch image a plain single-platform manifest without the unknown/unknown platform entry. +export BUILDX_NO_DEFAULT_ATTESTATIONS=1 + +docker build --platform "${PLATFORM}" \ + --build-arg ACR_CACHE_PREFIX="${ACR_CACHE_PREFIX}" \ + --build-arg UBUNTU_MIRROR_PREFIX="${UBUNTU_MIRROR_PREFIX}" \ + "${SECRET_ARGS[@]}" \ + -t "$3" -f "Dockerfile.$2" . diff --git a/utils/docker/publish_docker.sh b/utils/docker/publish_docker.sh index 7ec9f5549bcf8..007b3ec1f9af4 100755 --- a/utils/docker/publish_docker.sh +++ b/utils/docker/publish_docker.sh @@ -7,20 +7,19 @@ trap "cd $(pwd -P)" EXIT cd "$(dirname "$0")" MCR_IMAGE_NAME="playwright" -PW_VERSION=$(node ../../utils/workspace.js --get-version) RELEASE_CHANNEL="$1" -if [[ "${RELEASE_CHANNEL}" == "stable" ]]; then - if [[ "${PW_VERSION}" == *-* ]]; then - echo "ERROR: cannot publish stable docker with Playwright version '${PW_VERSION}'" - exit 1 - fi -elif [[ "${RELEASE_CHANNEL}" != "canary" ]]; then - echo "ERROR: unknown release channel - ${RELEASE_CHANNEL}" +if [[ "${RELEASE_CHANNEL}" != "stable" && "${RELEASE_CHANNEL}" != "canary" ]]; then + echo "ERROR: unknown release channel - '${RELEASE_CHANNEL}'" echo "Must be either 'stable' or 'canary'" exit 1 fi +PW_VERSION=$(node ../../utils/workspace.js --get-version) +if [[ "${RELEASE_CHANNEL}" == "stable" && "${PW_VERSION}" == *-* ]]; then + echo "ERROR: cannot publish stable docker with Playwright version '${PW_VERSION}'" + exit 1 +fi VERSION_TAG="v${PW_VERSION}" if [[ "${RELEASE_CHANNEL}" == "canary" ]]; then VERSION_TAG="v${PW_VERSION}-canary-$(date -u +'%Y%m%d%H%M%S')" @@ -45,6 +44,20 @@ RESOLUTE_TAGS=( "${VERSION_TAG}-resolute" ) +tags_for_flavor() { + local FLAVOR="$1" + if [[ "$FLAVOR" == "jammy" ]]; then + echo "${JAMMY_TAGS[@]}" + elif [[ "$FLAVOR" == "noble" ]]; then + echo "${NOBLE_TAGS[@]}" + elif [[ "$FLAVOR" == "resolute" ]]; then + echo "${RESOLUTE_TAGS[@]}" + else + echo "ERROR: unknown flavor - $FLAVOR. Must be either 'jammy', 'noble', or 'resolute'" >&2 + exit 1 + fi +} + tag_and_push() { local source="$1" local target="$2" @@ -68,25 +81,19 @@ install_oras_if_needed() { return fi local version="1.1.0" - curl -sLO "https://github.com/oras-project/oras/releases/download/v${version}/oras_${version}_linux_amd64.tar.gz" + local arch="amd64" + if [[ "$(uname -m)" == "aarch64" || "$(uname -m)" == "arm64" ]]; then + arch="arm64" + fi + curl -sLO "https://github.com/oras-project/oras/releases/download/v${version}/oras_${version}_linux_${arch}.tar.gz" mkdir -p oras - tar -zxf oras_${version}_linux_amd64.tar.gz -C oras - rm oras_${version}_linux_amd64.tar.gz + tar -zxf oras_${version}_linux_${arch}.tar.gz -C oras + rm oras_${version}_linux_${arch}.tar.gz } publish_docker_images_with_arch_suffix() { local FLAVOR="$1" - local TAGS=() - if [[ "$FLAVOR" == "jammy" ]]; then - TAGS=("${JAMMY_TAGS[@]}") - elif [[ "$FLAVOR" == "noble" ]]; then - TAGS=("${NOBLE_TAGS[@]}") - elif [[ "$FLAVOR" == "resolute" ]]; then - TAGS=("${RESOLUTE_TAGS[@]}") - else - echo "ERROR: unknown flavor - $FLAVOR. Must be either 'jammy', 'noble', or 'resolute'" - exit 1 - fi + local TAGS=($(tags_for_flavor "$FLAVOR")) local ARCH="$2" if [[ "$ARCH" != "amd64" && "$ARCH" != "arm64" ]]; then echo "ERROR: unknown arch - $ARCH. Must be either 'amd64' or 'arm64'" @@ -104,17 +111,7 @@ publish_docker_images_with_arch_suffix() { publish_docker_manifest () { local FLAVOR="$1" - local TAGS=() - if [[ "$FLAVOR" == "jammy" ]]; then - TAGS=("${JAMMY_TAGS[@]}") - elif [[ "$FLAVOR" == "noble" ]]; then - TAGS=("${NOBLE_TAGS[@]}") - elif [[ "$FLAVOR" == "resolute" ]]; then - TAGS=("${RESOLUTE_TAGS[@]}") - else - echo "ERROR: unknown flavor - $FLAVOR. Must be either 'jammy', 'noble', or 'resolute'" - exit 1 - fi + local TAGS=($(tags_for_flavor "$FLAVOR")) for ((i = 0; i < ${#TAGS[@]}; i++)) do local TAG="${TAGS[$i]}" @@ -131,17 +128,23 @@ publish_docker_manifest () { done } -# Ubuntu 22.04 -publish_docker_images_with_arch_suffix jammy amd64 -publish_docker_images_with_arch_suffix jammy arm64 -publish_docker_manifest jammy amd64 arm64 +build_and_push_arch() { + local ARCH="$1" + publish_docker_images_with_arch_suffix jammy "${ARCH}" # Ubuntu 22.04 + publish_docker_images_with_arch_suffix noble "${ARCH}" # Ubuntu 24.04 + publish_docker_images_with_arch_suffix resolute "${ARCH}" # Ubuntu 26.04 +} -# Ubuntu 24.04 -publish_docker_images_with_arch_suffix noble amd64 -publish_docker_images_with_arch_suffix noble arm64 -publish_docker_manifest noble amd64 arm64 +publish_manifests() { + publish_docker_manifest jammy amd64 arm64 # Ubuntu 22.04 + publish_docker_manifest noble amd64 arm64 # Ubuntu 24.04 + publish_docker_manifest resolute amd64 arm64 # Ubuntu 26.04 +} -# Ubuntu 26.04 -publish_docker_images_with_arch_suffix resolute amd64 -publish_docker_images_with_arch_suffix resolute arm64 -publish_docker_manifest resolute amd64 arm64 +# arm64 first: its QEMU-emulated builds must run while the host is fresh. Running +# them after the native amd64 builds have churned the host triggers a qemu +# segfault in aarch64 ldconfig during libc-bin setup. amd64 is a native build and +# is unaffected by preceding work, so it goes second. +build_and_push_arch arm64 +build_and_push_arch amd64 +publish_manifests diff --git a/utils/generate_injected.js b/utils/generate_injected.js index 3d0c594b07d0d..f62221f0d06cd 100644 --- a/utils/generate_injected.js +++ b/utils/generate_injected.js @@ -74,6 +74,12 @@ const injectedScripts = [ path.join(ROOT, 'packages', 'playwright-core', 'src', 'generated'), true, ], + [ + path.join(ROOT, 'packages', 'injected', 'src', 'bidiInsertText.ts'), + path.join(ROOT, 'packages', 'injected', 'lib'), + path.join(ROOT, 'packages', 'playwright-core', 'src', 'generated'), + true, + ], [ path.join(ROOT, 'packages', 'injected', 'src', 'webview', 'webViewInput.ts'), path.join(ROOT, 'packages', 'injected', 'lib'),