Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/src/library-python.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,7 @@ On Windows Python 3.7, Playwright sets the default event loop to `ProactorEventL
### Threading

Playwright's API is not thread-safe. If you are using Playwright in a multi-threaded environment, you should create a playwright instance per thread. See [threading issue](https://github.com/microsoft/playwright-python/issues/623) for more details.

### Cancelling `asyncio` tasks

Cancelling a task that is running a Playwright call is not supported and results in undefined behavior. If an operation has to outlive its caller, run it in a separate task and protect it with [`asyncio.shield()`](https://docs.python.org/3/library/asyncio-task.html#asyncio.shield).
1 change: 1 addition & 0 deletions packages/isomorphic/trace/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type ContextEntry = {
actions: ActionEntry[];
screenshots: trace.ScreenshotTraceEvent[];
ariaSnapshots: trace.AriaSnapshotTraceEvent[];
videos: trace.VideoTraceEvent[];
events: (trace.EventTraceEvent | trace.ConsoleMessageTraceEvent)[];
stdio: trace.StdioTraceEvent[];
errors: trace.ErrorTraceEvent[];
Expand Down
1 change: 1 addition & 0 deletions packages/isomorphic/trace/traceLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ function createEmptyContext(): ContextEntry {
actions: [],
screenshots: [],
ariaSnapshots: [],
videos: [],
events: [],
errors: [],
stdio: [],
Expand Down
5 changes: 5 additions & 0 deletions packages/isomorphic/trace/traceModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export class TraceModel {
readonly title?: string;
readonly options: trace.BrowserContextEventOptions;
readonly pages: PageEntry[];
readonly videos: trace.VideoTraceEvent[];
readonly actions: ActionEntry[];
readonly attachments: Attachment[];
readonly visibleAttachments: Attachment[];
Expand Down Expand Up @@ -105,6 +106,7 @@ export class TraceModel {
// Next call updates all timestamps for all events in library contexts, so it must be done first.
this.actions = mergeActionsAndUpdateTiming(contexts);
this.pages = ([] as PageEntry[]).concat(...contexts.map(c => c.pages));
this.videos = [];
this.wallTime = contexts.map(c => c.wallTime).reduce((prev, cur) => Math.min(prev || Number.MAX_VALUE, cur!), Number.MAX_VALUE);
this.startTime = contexts.map(c => c.startTime).reduce((prev, cur) => Math.min(prev, cur), Number.MAX_VALUE);
this.endTime = contexts.map(c => c.endTime).reduce((prev, cur) => Math.max(prev, cur), Number.MIN_VALUE);
Expand All @@ -126,6 +128,7 @@ export class TraceModel {
this._screenshots.set(`${event.callId}/${event.phase}`, event);
for (const event of context.ariaSnapshots || [])
this._ariaSnapshots.set(`${event.callId}/${event.phase}`, event);
this.videos.push(...(context.videos || []));
}
this.attachments = this.actions.flatMap(action => action.attachments?.map(attachment => ({ ...attachment, callId: action.callId, traceUri })) ?? []);
this.visibleAttachments = this.attachments.filter(attachment => !attachment.name.startsWith('_'));
Expand Down Expand Up @@ -344,6 +347,8 @@ function adjustMonotonicTime(context: ContextEntry, monotonicTimeDelta: number)
for (const frame of page.screencastFrames)
frame.timestamp += monotonicTimeDelta;
}
for (const video of context.videos || [])
video.timestampOrigin += monotonicTimeDelta;
for (const resource of context.resources) {
if (resource._monotonicTime)
resource._monotonicTime += monotonicTimeDelta;
Expand Down
4 changes: 4 additions & 0 deletions packages/isomorphic/trace/traceModernizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ export class TraceModernizer {
contextEntry.screenshots.push(event);
break;
}
case 'video': {
contextEntry.videos.push(event);
break;
}
case 'aria-snapshot': {
contextEntry.ariaSnapshots.push(event);
break;
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/browsers.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
},
{
"name": "webkit",
"revision": "2349",
"revision": "2354",
"installByDefault": true,
"revisionOverrides": {
"mac14": "2251",
Expand Down
17 changes: 17 additions & 0 deletions packages/playwright-core/src/tools/cli-daemon/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export async function initWorkspace(initSkills: string | undefined, initSkillsGl
const playwrightDir = path.join(cwd, '.playwright');
await fs.promises.mkdir(playwrightDir, { recursive: true });
console.log(`✅ Workspace initialized at \`${cwd}\`.`);
await patchGitIgnore(cwd);
}

const skills = initSkillsGlobal ?? initSkills;
Expand All @@ -111,6 +112,22 @@ export async function initWorkspace(initSkills: string | undefined, initSkillsGl
await ensureConfiguredBrowserInstalled();
}

async function patchGitIgnore(cwd: string) {
if (!fs.existsSync(path.join(cwd, '.git')))
return;
try {
const gitIgnorePath = path.join(cwd, '.gitignore');
const existing = await fs.promises.readFile(gitIgnorePath, 'utf8').catch(() => '');
if (existing.split('\n').some(line => line.trim() === '.playwright-cli/'))
return;
const separator = existing && !existing.endsWith('\n') ? '\n' : '';
await fs.promises.appendFile(gitIgnorePath, separator + '# Playwright CLI output (may contain credentials)\n.playwright-cli/\n');
console.log('✅ Added `.playwright-cli/` to `.gitignore`.');
} catch (error) {
console.log(`⚠️ Failed to update \`.gitignore\`: ${error instanceof Error ? error.message : error}`);
}
}

async function ensureConfiguredBrowserInstalled() {
if (getAsBooleanFromENV('PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD'))
return;
Expand Down
10 changes: 4 additions & 6 deletions packages/trace-viewer/src/ui/attachmentsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,14 @@ const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> =
return Math.min(Math.max(5, lineCount), 20) * lineHeight;
}, [attachmentText]);

const title = <span style={{ marginLeft: 5 }} ref={ref} aria-label={attachment.name}>
<span>{linkifyText(attachment.name)}</span>
{hasContent && <a style={{ marginLeft: 5 }} href={downloadURL(model, attachment)}>download</a>}
</span>;
const title = <span style={{ marginLeft: 5 }} ref={ref} aria-label={attachment.name}>{linkifyText(attachment.name)}</span>;
const downloadLink = hasContent && <a style={{ marginLeft: 5 }} href={downloadURL(model, attachment)}>download</a>;

if (!isTextAttachment || !hasContent)
return <div style={{ marginLeft: 20 }}>{title}</div>;
return <div style={{ marginLeft: 20 }}>{title}{downloadLink}</div>;

return <div className={clsx(flash && 'yellow-flash')}>
<Expandable title={title} expanded={expanded} setExpanded={setExpanded} expandOnTitleClick={true}>
<Expandable title={title} titleSuffix={downloadLink} expanded={expanded} setExpanded={setExpanded} expandOnTitleClick={true}>
{placeholder && <i>{placeholder}</i>}
</Expandable>
{expanded && attachmentText !== null && <div className='vbox' style={{ height: snippetHeight }}>
Expand Down
88 changes: 78 additions & 10 deletions packages/trace-viewer/src/ui/filmStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import * as React from 'react';
import { useMeasure, upperBound } from '@web/uiUtils';
import type { PageEntry } from '@isomorphic/trace/entries';
import { useTraceModel } from './traceModelContext';
import { useVideoThumbnails } from './videoThumbnails';
import type { VideoThumbnail } from './videoThumbnails';

export type FilmStripPreviewPoint = {
x: number;
Expand All @@ -38,18 +40,29 @@ export const FilmStrip: React.FunctionComponent<{
const [measure, ref] = useMeasure<HTMLDivElement>();
const lanesRef = React.useRef<HTMLDivElement>(null);

let pageIndex = 0;
const video = model?.videos?.[0];
const videoThumbnails = useVideoThumbnails(video, video && model ? model.createRelativeUrl(`file/${video.file}`) : undefined);

let laneIndex = 0;
if (lanesRef.current && previewPoint) {
const bounds = lanesRef.current.getBoundingClientRect();
pageIndex = ((previewPoint.clientY - bounds.top + lanesRef.current.scrollTop) / rowHeight) | 0;
laneIndex = ((previewPoint.clientY - bounds.top + lanesRef.current.scrollTop) / rowHeight) | 0;
}

const screencastFrames = model?.pages?.[pageIndex]?.screencastFrames;
const pageLanes = (model?.pages ?? []).filter(page => page.screencastFrames.length);
const videoLanes: VideoThumbnail[][] = videoThumbnails.length ? [videoThumbnails] : [];

let previewFrames: { timestamp: number, width: number, height: number, url: string }[] | undefined;
if (laneIndex < pageLanes.length)
previewFrames = model ? pageLanes[laneIndex]?.screencastFrames.map(frame => ({ ...frame, url: model.createRelativeUrl(`file/${frame.file}`) })) : undefined;
else
previewFrames = videoLanes[laneIndex - pageLanes.length];

let previewImage = undefined;
let previewSize = undefined;
if (previewPoint !== undefined && screencastFrames && screencastFrames.length) {
if (previewPoint !== undefined && previewFrames && previewFrames.length) {
const previewTime = boundaries.minimum + (boundaries.maximum - boundaries.minimum) * previewPoint.x / measure.width;
previewImage = screencastFrames[upperBound(screencastFrames, previewTime, timeComparator) - 1];
previewImage = previewFrames[upperBound(previewFrames, previewTime, timeComparator) - 1];
const fitInto = {
width: Math.min(800, (window.innerWidth / 2) | 0),
height: Math.min(800, (window.innerHeight / 2) | 0),
Expand All @@ -58,27 +71,82 @@ export const FilmStrip: React.FunctionComponent<{
}

return <div className='film-strip' ref={ref}>
<div className='film-strip-lanes' ref={lanesRef}>{
model?.pages.map((page, index) => page.screencastFrames.length ? <FilmStripLane
<div className='film-strip-lanes' ref={lanesRef}>
{pageLanes.map((page, index) => <FilmStripLane
boundaries={boundaries}
page={page}
width={measure.width}
key={index}
/> : null)
}</div>
/>)}
{videoLanes.map((thumbnails, index) => <VideoFilmStripLane
boundaries={boundaries}
thumbnails={thumbnails}
width={measure.width}
key={'video-' + index}
/>)}
</div>
{model && previewPoint && previewImage && previewSize &&
<div className='film-strip-hover' style={{
top: measure.bottom + 5,
left: Math.min(previewPoint.x, measure.width - previewSize.width - 10),
width: previewSize.width,
height: previewSize.height,
}}>
<img src={model.createRelativeUrl(`file/${previewImage.file}`)} width={previewSize.width} height={previewSize.height} />
<img src={previewImage.url} width={previewSize.width} height={previewSize.height} />
</div>
}
</div>;
};

const VideoFilmStripLane: React.FunctionComponent<{
boundaries: Boundaries,
thumbnails: VideoThumbnail[],
width: number,
}> = ({ boundaries, thumbnails, width }) => {
const viewportSize = { width: 0, height: 0 };
for (const thumbnail of thumbnails) {
viewportSize.width = Math.max(viewportSize.width, thumbnail.width);
viewportSize.height = Math.max(viewportSize.height, thumbnail.height);
}
const frameSize = inscribe(viewportSize, tileSize);
const startTime = thumbnails[0].timestamp;
const endTime = thumbnails[thumbnails.length - 1].timestamp;

const boundariesDuration = boundaries.maximum - boundaries.minimum;
const gapLeft = (startTime - boundaries.minimum) / boundariesDuration * width;
const gapRight = (boundaries.maximum - endTime) / boundariesDuration * width;
const effectiveWidth = (endTime - startTime) / boundariesDuration * width;
const frameCount = (effectiveWidth / (frameSize.width + 2 * frameMargin)) | 0;
const frameDuration = (endTime - startTime) / frameCount;

const frames: React.JSX.Element[] = [];
for (let i = 0; startTime && frameDuration && i < frameCount; ++i) {
const time = startTime + frameDuration * i;
const index = upperBound(thumbnails, time, timeComparator) - 1;
frames.push(<div className='film-strip-frame' key={i} style={{
width: frameSize.width,
height: frameSize.height,
backgroundImage: `url(${thumbnails[index].url})`,
backgroundSize: `${frameSize.width}px ${frameSize.height}px`,
margin: frameMargin,
marginRight: frameMargin,
}} />);
}
frames.push(<div className='film-strip-frame' key={frames.length} style={{
width: frameSize.width,
height: frameSize.height,
backgroundImage: `url(${thumbnails[thumbnails.length - 1].url})`,
backgroundSize: `${frameSize.width}px ${frameSize.height}px`,
margin: frameMargin,
marginRight: frameMargin,
}} />);

return <div className='film-strip-lane' style={{
marginLeft: gapLeft + 'px',
marginRight: gapRight + 'px',
}}>{frames}</div>;
};

const FilmStripLane: React.FunctionComponent<{
boundaries: Boundaries,
page: PageEntry,
Expand Down
10 changes: 4 additions & 6 deletions packages/trace-viewer/src/ui/networkResourceDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,11 @@ const ExpandableSection: React.FC<{
setExpanded={setExpanded}
expandOnTitleClick
title={
<>
<span className='network-request-details-header'>{title}
{showCount && <span className='network-request-details-header-count'> × {data?.length ?? 0}</span>}
</span>
{ titleChildren }
</>
<span className='network-request-details-header'>{title}
{showCount && <span className='network-request-details-header-count'> × {data?.length ?? 0}</span>}
</span>
}
titleSuffix={titleChildren}
className={className}
>
{data && <table className='network-request-details-table'>
Expand Down
Loading
Loading