diff --git a/packages/playwright-core/src/client/elementHandle.ts b/packages/playwright-core/src/client/elementHandle.ts index ae230e074b..3fb3b1d5bf 100644 --- a/packages/playwright-core/src/client/elementHandle.ts +++ b/packages/playwright-core/src/client/elementHandle.ts @@ -152,9 +152,10 @@ export class ElementHandle extends JSHandle implements throw new Error('Cannot set input files to detached element'); const converted = await convertInputFiles(files, frame.page().context()); if (converted.files) { + debugLogger.log('api', 'setting input buffers'); await this._elementChannel.setInputFiles({ files: converted.files, ...options }); } else { - debugLogger.log('api', 'switching to large files mode'); + debugLogger.log('api', 'setting input file paths'); await this._elementChannel.setInputFilePaths({ ...converted, ...options }); } } @@ -265,18 +266,13 @@ type InputFilesList = { export async function convertInputFiles(files: string | FilePayload | string[] | FilePayload[], context: BrowserContext): Promise { const items: (string | FilePayload)[] = Array.isArray(files) ? files.slice() : [files]; - const sizeLimit = 50 * 1024 * 1024; - const totalBufferSizeExceedsLimit = items.reduce((size, item) => size + ((typeof item === 'object' && 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.'); - - const stats = await Promise.all(items.filter(isString).map(item => fs.promises.stat(item as string))); - const totalFileSizeExceedsLimit = stats.reduce((acc, stat) => acc + stat.size, 0) > sizeLimit; - if (totalFileSizeExceedsLimit) { + 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.map(async item => { - assert(isString(item)); - const { writableStream: stream } = await context._channel.createTempFile({ name: path.basename(item) }); + 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; @@ -286,21 +282,13 @@ export async function convertInputFiles(files: string | FilePayload | string[] | return { localPaths: items.map(f => path.resolve(f as string)) as string[] }; } - const filePayloads: SetInputFilesFiles = await Promise.all(items.map(async item => { - if (typeof item === 'string') { - return { - name: path.basename(item), - buffer: await fs.promises.readFile(item) - }; - } else { - return { - name: item.name, - mimeType: item.mimeType, - buffer: item.buffer, - }; - } - })); - return { files: filePayloads }; + 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.'); + + return { files: payloads }; } export function determineScreenshotType(options: { path?: string, type?: 'png' | 'jpeg' }): 'png' | 'jpeg' | undefined { diff --git a/packages/playwright-core/src/client/frame.ts b/packages/playwright-core/src/client/frame.ts index db0b23530d..2114ee9f1f 100644 --- a/packages/playwright-core/src/client/frame.ts +++ b/packages/playwright-core/src/client/frame.ts @@ -402,9 +402,10 @@ export class Frame extends ChannelOwner implements api.Fr async setInputFiles(selector: string, files: string | FilePayload | string[] | FilePayload[], options: channels.FrameSetInputFilesOptions = {}): Promise { const converted = await convertInputFiles(files, this.page().context()); if (converted.files) { + debugLogger.log('api', 'setting input buffers'); await this._channel.setInputFiles({ selector, files: converted.files, ...options }); } else { - debugLogger.log('api', 'switching to large files mode'); + debugLogger.log('api', 'setting input file paths'); await this._channel.setInputFilePaths({ selector, ...converted, ...options }); } } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 229f4683c6..8e8a25bb4b 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -940,6 +940,7 @@ scheme.BrowserContextHarExportResult = tObject({ }); scheme.BrowserContextCreateTempFileParams = tObject({ name: tString, + lastModifiedMs: tOptional(tNumber), }); scheme.BrowserContextCreateTempFileResult = tObject({ writableStream: tChannel(['WritableStream']), diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index 715865e613..65bb47ee83 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -182,7 +182,7 @@ export class BrowserContextDispatcher extends Dispatcher implements channels.WritableStreamChannel { _type_WritableStream = true; - constructor(scope: BrowserContextDispatcher, stream: fs.WriteStream) { + private _lastModifiedMs: number | undefined; + + constructor(scope: BrowserContextDispatcher, stream: fs.WriteStream, lastModifiedMs?: number) { super(scope, { guid: 'writableStream@' + createGuid(), stream }, 'WritableStream', {}); + this._lastModifiedMs = lastModifiedMs; } async write(params: channels.WritableStreamWriteParams): Promise { @@ -41,6 +44,8 @@ export class WritableStreamDispatcher extends Dispatcher<{ guid: string, stream: async close() { const stream = this._object.stream; await new Promise(fulfill => stream.end(fulfill)); + if (this._lastModifiedMs) + await fs.promises.utimes(this.path(), new Date(this._lastModifiedMs), new Date(this._lastModifiedMs)); } path(): string { diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index 59adbebf61..a7aba4dd53 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -1702,9 +1702,10 @@ export type BrowserContextHarExportResult = { }; export type BrowserContextCreateTempFileParams = { name: string, + lastModifiedMs?: number, }; export type BrowserContextCreateTempFileOptions = { - + lastModifiedMs?: number, }; export type BrowserContextCreateTempFileResult = { writableStream: WritableStreamChannel, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index bfff9ff756..262954ce7e 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1162,6 +1162,7 @@ BrowserContext: createTempFile: parameters: name: string + lastModifiedMs: number? returns: writableStream: WritableStream diff --git a/tests/library/browsertype-connect.spec.ts b/tests/library/browsertype-connect.spec.ts index b5d480c5d8..5dbd271264 100644 --- a/tests/library/browsertype-connect.spec.ts +++ b/tests/library/browsertype-connect.spec.ts @@ -698,6 +698,26 @@ for (const kind of ['launchServer', 'run-server'] as const) { await Promise.all([uploadFile, file1.filepath].map(fs.promises.unlink)); }); + test('setInputFiles should preserve lastModified timestamp', async ({ connect, startRemoteServer, asset }) => { + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/27452' }); + const remoteServer = await startRemoteServer(kind); + const browser = await connect(remoteServer.wsEndpoint()); + const context = await browser.newContext(); + 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); + }); + test('should connect over http', async ({ connect, startRemoteServer, mode }) => { test.skip(mode !== 'default'); const remoteServer = await startRemoteServer(kind); diff --git a/tests/page/page-set-input-files.spec.ts b/tests/page/page-set-input-files.spec.ts index 9a684d7b2a..93d223a88c 100644 --- a/tests/page/page-set-input-files.spec.ts +++ b/tests/page/page-set-input-files.spec.ts @@ -613,4 +613,19 @@ it('input should trigger events when files changed second time', async ({ page, await input.setInputFiles(asset('pptr.png')); expect(await input.evaluate(e => (e as HTMLInputElement).files[0].name)).toBe('pptr.png'); expect(await events.evaluate(e => e)).toEqual(['input', 'change']); +}); + +it('should preserve lastModified timestamp', async ({ page, asset }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/27452' }); + 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); }); \ No newline at end of file