fix: preserve lastModified timestamp in setInputFiles (#27671)
Fixes #27452
This commit is contained in:
		
							parent
							
								
									7cd390b708
								
							
						
					
					
						commit
						bd58c0d5d2
					
				|  | @ -152,9 +152,10 @@ export class ElementHandle<T extends Node = Node> extends JSHandle<T> 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<InputFilesList> { | ||||
|   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 { | ||||
|  |  | |||
|  | @ -402,9 +402,10 @@ export class Frame extends ChannelOwner<channels.FrameChannel> implements api.Fr | |||
|   async setInputFiles(selector: string, files: string | FilePayload | string[] | FilePayload[], options: channels.FrameSetInputFilesOptions = {}): Promise<void> { | ||||
|     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 }); | ||||
|     } | ||||
|   } | ||||
|  |  | |||
|  | @ -940,6 +940,7 @@ scheme.BrowserContextHarExportResult = tObject({ | |||
| }); | ||||
| scheme.BrowserContextCreateTempFileParams = tObject({ | ||||
|   name: tString, | ||||
|   lastModifiedMs: tOptional(tNumber), | ||||
| }); | ||||
| scheme.BrowserContextCreateTempFileResult = tObject({ | ||||
|   writableStream: tChannel(['WritableStream']), | ||||
|  |  | |||
|  | @ -182,7 +182,7 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel | |||
|     await fs.promises.mkdir(tmpDir); | ||||
|     this._context._tempDirs.push(tmpDir); | ||||
|     const file = fs.createWriteStream(path.join(tmpDir, params.name)); | ||||
|     return { writableStream: new WritableStreamDispatcher(this, file) }; | ||||
|     return { writableStream: new WritableStreamDispatcher(this, file, params.lastModifiedMs) }; | ||||
|   } | ||||
| 
 | ||||
|   async setDefaultNavigationTimeoutNoReply(params: channels.BrowserContextSetDefaultNavigationTimeoutNoReplyParams) { | ||||
|  |  | |||
|  | @ -16,14 +16,17 @@ | |||
| 
 | ||||
| import type * as channels from '@protocol/channels'; | ||||
| import { Dispatcher } from './dispatcher'; | ||||
| import type * as fs from 'fs'; | ||||
| import * as fs from 'fs'; | ||||
| import { createGuid } from '../../utils'; | ||||
| import type { BrowserContextDispatcher } from './browserContextDispatcher'; | ||||
| 
 | ||||
| export class WritableStreamDispatcher extends Dispatcher<{ guid: string, stream: fs.WriteStream }, channels.WritableStreamChannel, BrowserContextDispatcher> 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<channels.WritableStreamWriteResult> { | ||||
|  | @ -41,6 +44,8 @@ export class WritableStreamDispatcher extends Dispatcher<{ guid: string, stream: | |||
|   async close() { | ||||
|     const stream = this._object.stream; | ||||
|     await new Promise<void>(fulfill => stream.end(fulfill)); | ||||
|     if (this._lastModifiedMs) | ||||
|       await fs.promises.utimes(this.path(), new Date(this._lastModifiedMs), new Date(this._lastModifiedMs)); | ||||
|   } | ||||
| 
 | ||||
|   path(): string { | ||||
|  |  | |||
|  | @ -1702,9 +1702,10 @@ export type BrowserContextHarExportResult = { | |||
| }; | ||||
| export type BrowserContextCreateTempFileParams = { | ||||
|   name: string, | ||||
|   lastModifiedMs?: number, | ||||
| }; | ||||
| export type BrowserContextCreateTempFileOptions = { | ||||
| 
 | ||||
|   lastModifiedMs?: number, | ||||
| }; | ||||
| export type BrowserContextCreateTempFileResult = { | ||||
|   writableStream: WritableStreamChannel, | ||||
|  |  | |||
|  | @ -1162,6 +1162,7 @@ BrowserContext: | |||
|     createTempFile: | ||||
|       parameters: | ||||
|         name: string | ||||
|         lastModifiedMs: number? | ||||
|       returns: | ||||
|         writableStream: WritableStream | ||||
| 
 | ||||
|  |  | |||
|  | @ -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(`<input type=file multiple=true/>`); | ||||
|       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); | ||||
|  |  | |||
|  | @ -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(`<input type=file multiple=true/>`); | ||||
|   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); | ||||
| }); | ||||
		Loading…
	
		Reference in New Issue