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');
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 {

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> {
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 });
}
}

View File

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

View File

@ -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) {

View File

@ -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 {

View File

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

View File

@ -1162,6 +1162,7 @@ BrowserContext:
createTempFile:
parameters:
name: string
lastModifiedMs: number?
returns:
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));
});
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);

View File

@ -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);
});