fix: preserve lastModified timestamp in setInputFiles (#27671)

Fixes #27452
This commit is contained in:
Yury Semikhatsky 2023-10-18 14:05:09 -07:00 committed by GitHub
parent 7cd390b708
commit bd58c0d5d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 64 additions and 32 deletions

View File

@ -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'); throw new Error('Cannot set input files to detached element');
const converted = await convertInputFiles(files, frame.page().context()); const converted = await convertInputFiles(files, frame.page().context());
if (converted.files) { if (converted.files) {
debugLogger.log('api', 'setting input buffers');
await this._elementChannel.setInputFiles({ files: converted.files, ...options }); await this._elementChannel.setInputFiles({ files: converted.files, ...options });
} else { } else {
debugLogger.log('api', 'switching to large files mode'); debugLogger.log('api', 'setting input file paths');
await this._elementChannel.setInputFilePaths({ ...converted, ...options }); 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> { export async function convertInputFiles(files: string | FilePayload | string[] | FilePayload[], context: BrowserContext): Promise<InputFilesList> {
const items: (string | FilePayload)[] = Array.isArray(files) ? files.slice() : [files]; const items: (string | FilePayload)[] = Array.isArray(files) ? files.slice() : [files];
const sizeLimit = 50 * 1024 * 1024; if (items.some(item => typeof item === 'string')) {
const totalBufferSizeExceedsLimit = items.reduce((size, item) => size + ((typeof item === 'object' && item.buffer) ? item.buffer.byteLength : 0), 0) > sizeLimit; if (!items.every(item => typeof item === 'string'))
if (totalBufferSizeExceedsLimit) throw new Error('File paths cannot be mixed with buffers');
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 (context._connection.isRemote()) { if (context._connection.isRemote()) {
const streams: channels.WritableStreamChannel[] = await Promise.all(items.map(async item => { const streams: channels.WritableStreamChannel[] = await Promise.all((items as string[]).map(async item => {
assert(isString(item)); const lastModifiedMs = (await fs.promises.stat(item)).mtimeMs;
const { writableStream: stream } = await context._channel.createTempFile({ name: path.basename(item) }); const { writableStream: stream } = await context._channel.createTempFile({ name: path.basename(item), lastModifiedMs });
const writable = WritableStream.from(stream); const writable = WritableStream.from(stream);
await pipelineAsync(fs.createReadStream(item), writable.stream()); await pipelineAsync(fs.createReadStream(item), writable.stream());
return 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[] }; return { localPaths: items.map(f => path.resolve(f as string)) as string[] };
} }
const filePayloads: SetInputFilesFiles = await Promise.all(items.map(async item => { const payloads = items as FilePayload[];
if (typeof item === 'string') { const sizeLimit = 50 * 1024 * 1024;
return { const totalBufferSizeExceedsLimit = payloads.reduce((size, item) => size + (item.buffer ? item.buffer.byteLength : 0), 0) > sizeLimit;
name: path.basename(item), if (totalBufferSizeExceedsLimit)
buffer: await fs.promises.readFile(item) throw new Error('Cannot set buffer larger than 50Mb, please write it to a file and pass its path instead.');
};
} else { return { files: payloads };
return {
name: item.name,
mimeType: item.mimeType,
buffer: item.buffer,
};
}
}));
return { files: filePayloads };
} }
export function determineScreenshotType(options: { path?: string, type?: 'png' | 'jpeg' }): 'png' | 'jpeg' | undefined { export function determineScreenshotType(options: { path?: string, type?: 'png' | 'jpeg' }): 'png' | 'jpeg' | undefined {

View File

@ -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> { async setInputFiles(selector: string, files: string | FilePayload | string[] | FilePayload[], options: channels.FrameSetInputFilesOptions = {}): Promise<void> {
const converted = await convertInputFiles(files, this.page().context()); const converted = await convertInputFiles(files, this.page().context());
if (converted.files) { if (converted.files) {
debugLogger.log('api', 'setting input buffers');
await this._channel.setInputFiles({ selector, files: converted.files, ...options }); await this._channel.setInputFiles({ selector, files: converted.files, ...options });
} else { } else {
debugLogger.log('api', 'switching to large files mode'); debugLogger.log('api', 'setting input file paths');
await this._channel.setInputFilePaths({ selector, ...converted, ...options }); await this._channel.setInputFilePaths({ selector, ...converted, ...options });
} }
} }

View File

@ -940,6 +940,7 @@ scheme.BrowserContextHarExportResult = tObject({
}); });
scheme.BrowserContextCreateTempFileParams = tObject({ scheme.BrowserContextCreateTempFileParams = tObject({
name: tString, name: tString,
lastModifiedMs: tOptional(tNumber),
}); });
scheme.BrowserContextCreateTempFileResult = tObject({ scheme.BrowserContextCreateTempFileResult = tObject({
writableStream: tChannel(['WritableStream']), writableStream: tChannel(['WritableStream']),

View File

@ -182,7 +182,7 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
await fs.promises.mkdir(tmpDir); await fs.promises.mkdir(tmpDir);
this._context._tempDirs.push(tmpDir); this._context._tempDirs.push(tmpDir);
const file = fs.createWriteStream(path.join(tmpDir, params.name)); 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) { async setDefaultNavigationTimeoutNoReply(params: channels.BrowserContextSetDefaultNavigationTimeoutNoReplyParams) {

View File

@ -16,14 +16,17 @@
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
import { Dispatcher } from './dispatcher'; import { Dispatcher } from './dispatcher';
import type * as fs from 'fs'; import * as fs from 'fs';
import { createGuid } from '../../utils'; import { createGuid } from '../../utils';
import type { BrowserContextDispatcher } from './browserContextDispatcher'; import type { BrowserContextDispatcher } from './browserContextDispatcher';
export class WritableStreamDispatcher extends Dispatcher<{ guid: string, stream: fs.WriteStream }, channels.WritableStreamChannel, BrowserContextDispatcher> implements channels.WritableStreamChannel { export class WritableStreamDispatcher extends Dispatcher<{ guid: string, stream: fs.WriteStream }, channels.WritableStreamChannel, BrowserContextDispatcher> implements channels.WritableStreamChannel {
_type_WritableStream = true; _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', {}); super(scope, { guid: 'writableStream@' + createGuid(), stream }, 'WritableStream', {});
this._lastModifiedMs = lastModifiedMs;
} }
async write(params: channels.WritableStreamWriteParams): Promise<channels.WritableStreamWriteResult> { async write(params: channels.WritableStreamWriteParams): Promise<channels.WritableStreamWriteResult> {
@ -41,6 +44,8 @@ export class WritableStreamDispatcher extends Dispatcher<{ guid: string, stream:
async close() { async close() {
const stream = this._object.stream; const stream = this._object.stream;
await new Promise<void>(fulfill => stream.end(fulfill)); 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 { path(): string {

View File

@ -1702,9 +1702,10 @@ export type BrowserContextHarExportResult = {
}; };
export type BrowserContextCreateTempFileParams = { export type BrowserContextCreateTempFileParams = {
name: string, name: string,
lastModifiedMs?: number,
}; };
export type BrowserContextCreateTempFileOptions = { export type BrowserContextCreateTempFileOptions = {
lastModifiedMs?: number,
}; };
export type BrowserContextCreateTempFileResult = { export type BrowserContextCreateTempFileResult = {
writableStream: WritableStreamChannel, writableStream: WritableStreamChannel,

View File

@ -1162,6 +1162,7 @@ BrowserContext:
createTempFile: createTempFile:
parameters: parameters:
name: string name: string
lastModifiedMs: number?
returns: returns:
writableStream: WritableStream writableStream: WritableStream

View File

@ -698,6 +698,26 @@ for (const kind of ['launchServer', 'run-server'] as const) {
await Promise.all([uploadFile, file1.filepath].map(fs.promises.unlink)); 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('should connect over http', async ({ connect, startRemoteServer, mode }) => {
test.skip(mode !== 'default'); test.skip(mode !== 'default');
const remoteServer = await startRemoteServer(kind); const remoteServer = await startRemoteServer(kind);

View File

@ -613,4 +613,19 @@ it('input should trigger events when files changed second time', async ({ page,
await input.setInputFiles(asset('pptr.png')); await input.setInputFiles(asset('pptr.png'));
expect(await input.evaluate(e => (e as HTMLInputElement).files[0].name)).toBe('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']); 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);
}); });