diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index a74996bbe2..9ed649e02a 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -151,6 +151,10 @@ export class BrowserContext extends ChannelOwner this.tracing._tracesDir = browserOptions.tracesDir; } + _isLocalBrowserOnServer(): boolean { + return this._initializer.isLocalBrowserOnServer; + } + private _onPage(page: Page): void { this._pages.add(page); this.emit(Events.BrowserContext.Page, page); diff --git a/packages/playwright-core/src/client/elementHandle.ts b/packages/playwright-core/src/client/elementHandle.ts index 3fb3b1d5bf..794e88134f 100644 --- a/packages/playwright-core/src/client/elementHandle.ts +++ b/packages/playwright-core/src/client/elementHandle.ts @@ -263,31 +263,65 @@ type InputFilesList = { localPaths?: string[]; streams?: channels.WritableStreamChannel[]; }; + +const filePayloadSizeLimit = 50 * 1024 * 1024; + +function filePayloadExceedsSizeLimit(payloads: FilePayload[]) { + return payloads.reduce((size, item) => size + (item.buffer ? item.buffer.byteLength : 0), 0) >= filePayloadSizeLimit; +} + +async function filesExceedSizeLimit(files: string[]) { + const sizes = await Promise.all(files.map(async file => (await fs.promises.stat(file)).size)); + return sizes.reduce((total, size) => total + size, 0) >= filePayloadSizeLimit; +} + +async function readFilesIntoBuffers(items: string[]): Promise { + const filePayloads: SetInputFilesFiles = await Promise.all((items as string[]).map(async item => { + return { + name: path.basename(item), + buffer: await fs.promises.readFile(item), + lastModifiedMs: (await fs.promises.stat(item)).mtimeMs, + }; + })); + return filePayloads; +} + export async function convertInputFiles(files: string | FilePayload | string[] | FilePayload[], context: BrowserContext): Promise { const items: (string | FilePayload)[] = Array.isArray(files) ? files.slice() : [files]; if (items.some(item => typeof item === 'string')) { if (!items.every(item => typeof item === 'string')) throw new Error('File paths cannot be mixed with buffers'); + if (context._connection.isRemote()) { - const streams: channels.WritableStreamChannel[] = await Promise.all((items as string[]).map(async item => { - const lastModifiedMs = (await fs.promises.stat(item)).mtimeMs; - const { writableStream: stream } = await context._channel.createTempFile({ name: path.basename(item), lastModifiedMs }); - const writable = WritableStream.from(stream); - await pipelineAsync(fs.createReadStream(item), writable.stream()); - return stream; - })); - return { streams }; + if (context._isLocalBrowserOnServer()) { + const streams: channels.WritableStreamChannel[] = await Promise.all((items as string[]).map(async item => { + const lastModifiedMs = (await fs.promises.stat(item)).mtimeMs; + const { writableStream: stream } = await context._channel.createTempFile({ name: path.basename(item), lastModifiedMs }); + const writable = WritableStream.from(stream); + await pipelineAsync(fs.createReadStream(item), writable.stream()); + return stream; + })); + return { streams }; + } + if (await filesExceedSizeLimit(items as string[])) + throw new Error('Cannot transfer files larger than 50Mb to a browser not co-located with the server'); + return { files: await readFilesIntoBuffers(items as string[]) }; } - return { localPaths: items.map(f => path.resolve(f as string)) as string[] }; + if (context._isLocalBrowserOnServer()) + return { localPaths: items.map(f => path.resolve(f as string)) as string[] }; + if (await filesExceedSizeLimit(items as string[])) + throw new Error('Cannot transfer files larger than 50Mb to a browser not co-located with the server'); + return { files: await readFilesIntoBuffers(items as string[]) }; } const payloads = items as FilePayload[]; - const sizeLimit = 50 * 1024 * 1024; - const totalBufferSizeExceedsLimit = payloads.reduce((size, item) => size + (item.buffer ? item.buffer.byteLength : 0), 0) > sizeLimit; - if (totalBufferSizeExceedsLimit) - throw new Error('Cannot set buffer larger than 50Mb, please write it to a file and pass its path instead.'); - + if (filePayloadExceedsSizeLimit(payloads)) { + let error = 'Cannot set buffer larger than 50Mb'; + if (context._isLocalBrowserOnServer()) + error += ', please write it to a file and pass its path instead.'; + throw new Error(error); + } return { files: payloads }; } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 0ea38e7f80..eb1fd7dc24 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -762,6 +762,7 @@ scheme.ElectronApplicationWaitForEventInfoResult = tType('EventTargetWaitForEven scheme.AndroidDeviceWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); scheme.BrowserContextInitializer = tObject({ isChromium: tBoolean, + isLocalBrowserOnServer: tBoolean, requestContext: tChannel(['APIRequestContext']), tracing: tChannel(['Tracing']), }); @@ -1560,6 +1561,7 @@ scheme.FrameSetInputFilesParams = tObject({ name: tString, mimeType: tOptional(tString), buffer: tBinary, + lastModifiedMs: tOptional(tNumber), })), timeout: tOptional(tNumber), noWaitAfter: tOptional(tBoolean), @@ -1933,6 +1935,7 @@ scheme.ElementHandleSetInputFilesParams = tObject({ name: tString, mimeType: tOptional(tString), buffer: tBinary, + lastModifiedMs: tOptional(tNumber), })), timeout: tOptional(tNumber), noWaitAfter: tOptional(tBoolean), diff --git a/packages/playwright-core/src/server/browser.ts b/packages/playwright-core/src/server/browser.ts index 0d740b1bf8..425b812b31 100644 --- a/packages/playwright-core/src/server/browser.ts +++ b/packages/playwright-core/src/server/browser.ts @@ -65,6 +65,7 @@ export abstract class Browser extends SdkObject { readonly _idToVideo = new Map(); private _contextForReuse: { context: BrowserContext, hash: string } | undefined; _closeReason: string | undefined; + _isCollocatedWithServer: boolean = true; constructor(parent: SdkObject, options: BrowserOptions) { super(parent, 'browser'); diff --git a/packages/playwright-core/src/server/chromium/chromium.ts b/packages/playwright-core/src/server/chromium/chromium.ts index f0bf0b7376..c3988d4648 100644 --- a/packages/playwright-core/src/server/chromium/chromium.ts +++ b/packages/playwright-core/src/server/chromium/chromium.ts @@ -120,6 +120,7 @@ export class Chromium extends BrowserType { validateBrowserContextOptions(persistent, browserOptions); progress.throwIfAborted(); const browser = await CRBrowser.connect(this.attribution.playwright, chromeTransport, browserOptions); + browser._isCollocatedWithServer = false; browser.on(Browser.Events.Disconnected, doCleanup); return browser; } diff --git a/packages/playwright-core/src/server/chromium/crBrowser.ts b/packages/playwright-core/src/server/chromium/crBrowser.ts index 498f08298d..016663707e 100644 --- a/packages/playwright-core/src/server/chromium/crBrowser.ts +++ b/packages/playwright-core/src/server/chromium/crBrowser.ts @@ -59,6 +59,8 @@ export class CRBrowser extends Browser { const connection = new CRConnection(transport, options.protocolLogger, options.browserLogsCollector); const browser = new CRBrowser(parent, connection, options); browser._devtools = devtools; + if (browser.isClank()) + browser._isCollocatedWithServer = false; const session = connection.rootSession; if ((options as any).__testHookOnConnectToBrowser) await (options as any).__testHookOnConnectToBrowser(); diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index 65bb47ee83..b64384b029 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -53,6 +53,7 @@ export class BrowserContextDispatcher extends Dispatcher { + if (!this._context._browser._isCollocatedWithServer) + throw new Error('Cannot create temp file: the browser is not co-located with the server'); const dir = this._context._browser.options.artifactsDir; const tmpDir = path.join(dir, 'upload-' + createGuid()); await fs.promises.mkdir(tmpDir); diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index ba0a54818f..a49024b046 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -597,6 +597,7 @@ export class ElementHandle extends js.JSHandle { name: payload.name, mimeType: payload.mimeType || mime.getType(payload.name) || 'application/octet-stream', buffer: payload.buffer.toString('base64'), + lastModifiedMs: payload.lastModifiedMs }); } } diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 5f6fd0aae7..d19603b827 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -824,7 +824,7 @@ export class InjectedScript { return 'done'; } - setInputFiles(node: Node, payloads: { name: string, mimeType: string, buffer: string }[]) { + setInputFiles(node: Node, payloads: { name: string, mimeType: string, buffer: string, lastModifiedMs?: number }[]) { if (node.nodeType !== Node.ELEMENT_NODE) return 'Node is not of type HTMLElement'; const element: Element | undefined = node as Element; @@ -837,7 +837,7 @@ export class InjectedScript { const files = payloads.map(file => { const bytes = Uint8Array.from(atob(file.buffer), c => c.charCodeAt(0)); - return new File([bytes], file.name, { type: file.mimeType }); + return new File([bytes], file.name, { type: file.mimeType, lastModified: file.lastModifiedMs }); }); const dt = new DataTransfer(); for (const file of files) diff --git a/packages/playwright-core/src/server/types.ts b/packages/playwright-core/src/server/types.ts index 3983037626..e2ba06d50b 100644 --- a/packages/playwright-core/src/server/types.ts +++ b/packages/playwright-core/src/server/types.ts @@ -76,6 +76,7 @@ export type FilePayload = { name: string, mimeType: string, buffer: string, + lastModifiedMs?: number, }; export type MediaType = 'screen' | 'print' | 'no-override'; diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index 8b252040d5..6fd4bd651a 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -1407,6 +1407,7 @@ export interface EventTargetEvents { // ----------- BrowserContext ----------- export type BrowserContextInitializer = { isChromium: boolean, + isLocalBrowserOnServer: boolean, requestContext: APIRequestContextChannel, tracing: TracingChannel, }; @@ -2795,6 +2796,7 @@ export type FrameSetInputFilesParams = { name: string, mimeType?: string, buffer: Binary, + lastModifiedMs?: number, }[], timeout?: number, noWaitAfter?: boolean, @@ -3425,6 +3427,7 @@ export type ElementHandleSetInputFilesParams = { name: string, mimeType?: string, buffer: Binary, + lastModifiedMs?: number, }[], timeout?: number, noWaitAfter?: boolean, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 0dd3c05e63..56f6652717 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1012,6 +1012,7 @@ BrowserContext: initializer: isChromium: boolean + isLocalBrowserOnServer: boolean requestContext: APIRequestContext tracing: Tracing @@ -2118,6 +2119,7 @@ Frame: name: string mimeType: string? buffer: binary + lastModifiedMs: number? timeout: number? noWaitAfter: boolean? flags: @@ -2686,6 +2688,7 @@ ElementHandle: name: string mimeType: string? buffer: binary + lastModifiedMs: number? timeout: number? noWaitAfter: boolean? flags: diff --git a/tests/library/chromium/connect-over-cdp.spec.ts b/tests/library/chromium/connect-over-cdp.spec.ts index 24a24e8949..a5d52c5e57 100644 --- a/tests/library/chromium/connect-over-cdp.spec.ts +++ b/tests/library/chromium/connect-over-cdp.spec.ts @@ -470,3 +470,33 @@ test('should allow tracing over cdp session', async ({ browserType, trace }, tes await browserServer.close(); } }); + +test('setInputFiles should preserve lastModified timestamp', async ({ browserType, asset }) => { + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/27452' }); + + const port = 9339 + test.info().workerIndex; + const browserServer = await browserType.launch({ + args: ['--remote-debugging-port=' + port] + }); + try { + const cdpBrowser = await browserType.connectOverCDP({ + endpointURL: `http://127.0.0.1:${port}/`, + }); + const [context] = cdpBrowser.contexts(); + const page = await context.newPage(); + await page.setContent(``); + const input = page.locator('input'); + const files = ['file-to-upload.txt', 'file-to-upload-2.txt']; + await input.setInputFiles(files.map(f => asset(f))); + expect(await input.evaluate(e => [...(e as HTMLInputElement).files].map(f => f.name))).toEqual(files); + const timestamps = await input.evaluate(e => [...(e as HTMLInputElement).files].map(f => f.lastModified)); + const expectedTimestamps = files.map(file => Math.round(fs.statSync(asset(file)).mtimeMs)); + // On Linux browser sometimes reduces the timestamp by 1ms: 1696272058110.0715 -> 1696272058109 or even + // rounds it to seconds in WebKit: 1696272058110 -> 1696272058000. + for (let i = 0; i < timestamps.length; i++) + expect(Math.abs(timestamps[i] - expectedTimestamps[i]), `expected: ${expectedTimestamps}; actual: ${timestamps}`).toBeLessThan(1000); + await cdpBrowser.close(); + } finally { + await browserServer.close(); + } +});