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);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -614,3 +614,18 @@ it('input should trigger events when files changed second time', async ({ page,
 | 
			
		|||
  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