Docker 中的浏览器平台、图形与 Playwright

最后更新: 2022-08-21 22:50:08 +0800 +0800
原文以英文撰写。您正在阅读AI翻译版本。

当测试建模文档加载、布局、网络和渲染,而不是把页面当作静态字符串处理时,浏览器自动化才能可靠运行。

测量浏览器和 DOM #

浏览器尺寸回答的是不同的问题:

API测量对象
window.innerWidth 和 innerHeight布局视口,包括已渲染的滚动条
window.outerWidth 和 outerHeight浏览器窗口边界
screen.width 和 screen.height以 CSS 像素表示的屏幕尺寸
screen.availWidth 和 availHeight排除操作系统保留区域后的屏幕空间
element.clientWidth 和 clientHeight内容框加内边距,不包括边框和滚动条
element.scrollWidth 和 scrollHeight可滚动内容范围
element.offsetWidth 和 offsetHeight边框盒尺寸,四舍五入为整数像素
element.getBoundingClientRect()视口坐标中的边框盒几何信息,保留小数

没有布局框的内联元素的 clientWidth 为 0。对于根元素,它描述的是不包含经典滚动条的布局视口。不要在未加上滚动偏移量的情况下混用 getBoundingClientRect() 的视口坐标和文档坐标。

使用有效的 HTML 结构和资源提示 #

<link> 表示与外部资源的关系。它的 rel 属性是由空格分隔的一组标准化链接类型:

html
<link rel="stylesheet" href="/site.css">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">

preconnect 会提前建立传输连接;只能对页面将要联系的源使用它。它不会下载样式表、字体或脚本。

<meta> 提供字符集、视口、描述和 HTTP 等效指令等文档元数据。将 <meta charset="utf-8"> 放在 head 的前部,使解析器能够正确解码后续字节。

<noscript> 在脚本被禁用或不受支持时提供后备内容。它的解析行为取决于它出现在 head 还是 body,因此应在 HTML 验证器和浏览器中测试生成的标记。

<p> 元素不能包含另一个段落。HTML 解析器会在内层段落之前关闭外层段落,从而使 DOM 与源文本不同。模板代码应改为构造兄弟元素或语义容器:

html
<div class="notice">
  <p>First paragraph.</p>
  <p>Second paragraph.</p>
</div>

对脚本加载和模块依赖进行建模 #

没有 async 或 defer 的经典脚本会在浏览器获取并执行脚本期间阻塞 HTML 解析。因此,脚本的位置决定了哪些节点和全局变量可用:

html
<script src="/jquery.js"></script>
<script src="/plugin-that-needs-jquery.js"></script>

第二个脚本使用第一个脚本创建的全局变量,因此必须保持该顺序。将依赖脚本移动到提供者之前会引发引用错误。

形式是否阻塞解析器执行顺序DOM 可用性
经典脚本是文档顺序标签之前已解析的节点
defer获取不阻塞解析完成后的文档顺序完整的已解析文档
async获取不阻塞先完成者先执行不与解析完成绑定
type="module"获取不阻塞模块依赖顺序默认延迟执行

defer 适合需要保持顺序的应用脚本。async 适合分析脚本等独立脚本,因为这些脚本不能依赖文档位置或其他脚本。模块默认延迟执行,会解析 import 依赖,拥有自己的作用域,并且每个模块 URL 只执行一次。经典代码必须在解析完成后运行时使用 DOMContentLoaded;只有在图片、样式表和子框架也相关时才使用 load。

动态追加的脚本默认是异步的。只有在加载器明确需要串行化经典脚本时,才应在插入前设置 script.async = false。部署约束允许时,应优先使用原生 ES 模块和显式的 import 依赖,而不是自定义加载器。

从约束出发设计响应式 CSS #

CSS 盒模型包含内容、内边距、边框和外边距。box-sizing 决定声明的宽度和高度是否包含内边距与边框:

css
*, *::before, *::after {
  box-sizing: border-box;
}

使用 border-box 时,width: 20rem 表示边框盒宽度。使用初始值 content-box 时,内边距和边框会加到渲染宽度上。通用的边框盒规则使组件尺寸更容易组合。

Flexbox 布局一维序列。Grid 布局显式的行和列。工具栏或线性内容流适合使用 flex;当项目必须沿两个轴对齐时,应使用 grid:

css
.cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
  gap: 1rem;
}

minmax 表达式在窄视口中允许单列,同时保持列的可读性。gap 属于布局容器,可以避免外边距折叠或 first/last-child 例外。

媒体查询根据环境特性选择规则:

css
@media (width >= 48rem) {
  .sidebar-layout {
    grid-template-columns: minmax(16rem, 1fr) minmax(0, 3fr);
  }
}

@media (prefers-reduced-motion: reduce) {
  * { animation-duration: 1ms; transition-duration: 1ms; }
}

当视口改变设计可用空间时,应使用视口断点。当可复用组件可能出现在同一页面的不同宽度区域中时,容器查询更合适。

CSS 长度使用不同的参考框。rem 参考根字体大小,em 参考当前字体大小,百分比取决于具体属性,vw 和 vh 参考视口尺寸。如果内容需要,应在字体栈中保留非拉丁字符的字形覆盖范围:

css
.cjk-sample::before {
  content: "漢";
  font-family: "Noto Sans CJK SC", sans-serif;
}

该字形用于测试字体覆盖范围,并非正文内容。

尊重用户的颜色偏好 #

声明同时支持两种配色方案,然后定义语义化颜色变量:

css
:root {
  color-scheme: light dark;
  --surface: Canvas;
  --text: CanvasText;
  --accent: #2867c8;
}

@media (prefers-color-scheme: dark) {
  :root { --accent: #8ab4f8; }
}

prefers-color-scheme 反映操作系统或用户代理的偏好。由应用控制的主题选择器可以通过在根元素上设置属性来覆盖变量,但应在已保存的偏好、用户的显式选择和系统偏好之间保留清晰的优先级规则。

嵌入式 SVG 或 iframe 是独立文档。父文档的 CSS 自定义属性不会跨越该边界;嵌入资源需要自己的样式,或需要有意设计的消息传递和主题机制。

使用可预测的后备字体加载字体 #

字体族包含常规、斜体、粗体和粗斜体等相关字面。字形是字符的可见表示。浏览器会按顺序遍历 font-family,为每个字符选择字体:

css
body {
  font-family: "Example Sans", system-ui, sans-serif;
}

当某个字面缺少某个字形时,可以从后续字体族中选择后备字体。名称包含空格的字体族名称必须加引号。

@font-face 将字体族名称与可下载资源关联起来:

css
@font-face {
  font-family: "Example Sans";
  src: url("/fonts/example-sans.woff2") format("woff2");
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
}

WOFF2 是用于 OpenType 字体数据的压缩 Web 字体容器。可变字体可以公开 wght、wdth、opsz 和 ital 等轴;应将声明的范围限制为网站实际发布的字面。font-display: swap 会在 Web 字体加载期间显示后备文本,因此应选择度量兼容的后备字体,或使用字体度量覆盖来减少布局偏移。

window.queryLocalFonts() 需要权限,并且浏览器支持有限。不要让网站的渲染依赖于枚举本地字体。

正确缩放 Canvas #

HTML 的 width 和 height 属性设置 Canvas 的设备像素绘图缓冲区。CSS 的 width 和 height 设置其显示框。如果以不同的 CSS 尺寸显示 400×800 的绘图缓冲区,浏览器会缩放位图。

为了进行清晰的高密度渲染,应根据 CSS 框和设备像素比设置后备存储大小:

js
const rect = canvas.getBoundingClientRect();
const scale = window.devicePixelRatio;
canvas.width = Math.round(rect.width * scale);
canvas.height = Math.round(rect.height * scale);

const context = canvas.getContext("2d");
context.setTransform(scale, 0, 0, scale, 0, 0);

调整大小后、绘制之前要重置变换。当大型 Canvas 的内存使用量可能过高时,应限制缩放比例。

跟踪 CSS 规范和图标 #

CSS 是一组模块,而不是一次单体式发布。CSS 快照描述某个时间点上一组协调一致的成熟模块;之后各模块会独立演进。不要根据命名的 CSS 级别推断支持情况,而应检查模块的功能状态和浏览器支持情况。

图标在可访问性、样式和加载方面存在不同的权衡:

形式优点限制
图标字体继承 color 和文本大小单色字形限制以及字体加载
Unicode 字符无需额外资源语义和外观因字体和平台而异
内联 SVG可访问结构和逐部分样式增加 HTML 或组件复杂度
外部 SVG 或光栅图像可作为独立资源缓存为内部细节设置样式需要显式设计

对于仅包含图标的控件,应使用可访问名称:

html
<button type="button" aria-label="Search">
  <svg aria-hidden="true" viewBox="0 0 24 24">...</svg>
</button>

只有在按钮通过文本或 aria-label 提供可访问名称时,才应将 aria-hidden="true" 放在装饰性 SVG 上。

通过 HTTP 传输视频 #

浏览器会为可跳转媒体请求字节范围:

http
GET /movie.mp4 HTTP/1.1
Range: bytes=32243712-32955076
If-Range: "entity-tag"

符合规范的范围响应状态为 206 Partial Content,并包含 Content-Range 响应头。应提供 Accept-Ranges: bytes、稳定的验证器以及正确的媒体类型。忽略范围请求的服务器可以返回 200,但跳转会变得昂贵,因为客户端可能需要整个文件。

MPEG-DASH 将演示内容拆分为媒体片段,并由 MPD 清单描述。客户端选择具有不同比特率或分辨率的表示,并随着带宽变化调整片段选择。片段需要具有适合切换的关键帧边界;如果跨越片段边界编码一个很长的 GOP,就会损害独立解码。

HLS 通过播放列表格式遵循相同的自适应分段原则。浏览器支持范围不同,因此应根据目标平台选择播放器和打包格式,而不要假定 <video src> 能够消费所有清单。

在 TypeScript 中建模枚举值 #

接受固定值集合之一的 API 参数不需要使用数值 TypeScript enum。字符串字面量联合能够保留线路值:

ts
type Status = "queued" | "running" | "failed";

function retry(status: Status): boolean {
  return status === "failed";
}

需要运行时迭代和单一事实来源时,可以使用常量对象:

ts
const Status = {
  queued: "queued",
  running: "running",
  failed: "failed",
} as const;

type Status = typeof Status[keyof typeof Status];

对于规定数值的协议,数值枚举可能合适,但其反向映射和生成的 JavaScript 并非没有成本。应将更丰富的预定义值建模为带有判别字段和显式行为的对象,而不是把业务逻辑强行放入枚举成员。

解释下载和内联浏览器行为 #

HTTP 响应可以要求浏览器下载内容:

http
Content-Type: application/pdf
Content-Disposition: attachment; filename="guide.pdf"

attachment 请求下载行为,而 inline 允许在支持的查看器中显示。浏览器、用户偏好、内容类型、沙箱策略和安全响应头都可能影响最终行为。X-Content-Type-Options: nosniff 会阻止类型嗅探,因此正确的 Content-Type 很重要。应通过浏览器自动化测试下载,而不要假定响应头会在不同浏览器中产生相同的 UI 行为。

通过 GPU 管线渲染帧 #

应用通过 WebGL、WebGPU、Vulkan、Metal 或 Direct3D 等 API 提交缓冲区、纹理、着色器程序和绘制命令。随后,驱动程序和 GPU 按照概念上的管线处理这些数据:

  1. 顶点着色器变换每个顶点,并生成 varying 属性。
  2. 图元装配将顶点组合为线、三角形或点。
  3. 裁剪移除或拆分位于裁剪空间之外的几何体。
  4. 光栅化生成覆盖采样位置的片段。
  5. 片段着色器计算每个片段的输出。
  6. 深度、模板和混合操作将输出与帧缓冲区合并。

在透视除法之前,裁剪空间坐标会与齐次裁剪边界进行比较。穿过裁剪平面的三角形会被拆分,因此片段着色器不会接收到可见裁剪图元之外的覆盖范围。

GPU 接口会在 CPU-GPU 边界之间移动数据。将静态几何体保存在设备驻留缓冲区中,使用有界分配更新动态数据,并避免同步读回,因为同步读回会迫使 CPU 和 GPU 的进度会合。

定位摄像机并为几何体着色 #

摄像机将世界坐标变换为视图坐标,然后投影到裁剪空间。透视摄像机会将远处物体映射到更小的屏幕区域;正交摄像机则在深度方向上保持平行线和表观尺寸。在常见的 Three.js 约定中,位于 (0, 0, 0) 且方向为单位方向的摄像机沿负 Z 轴观察,正 Y 轴向上。

Lambert 漫反射光照模型描述哑光表面:

text
diffuse = albedo × lightColor × max(dot(normal, lightDirection), 0)

点积表示表面法线与光照方向之间夹角的余弦。Blinn-Phong 使用光照方向与视线方向之间的半程向量,增加经验性的镜面反射项。两个模型都要求正确变换法线:对于非均匀缩放,应使用模型视图矩阵的逆转置矩阵。

Three.js 的摄像机和材质在更高层次上提供了这些概念。设置摄像机位置,调用 camera.lookAt(target),更改宽高比后更新投影矩阵,并在场景更新后进行渲染。不要假定默认摄像机方向适合自定义坐标系。

合成透明度和颜色 #

标准的 source-over alpha 合成会组合源和目标:

text
out.rgb = source.rgb × source.a + destination.rgb × (1 - source.a)
out.a   = source.a + destination.a × (1 - source.a)

该公式假设源 RGB 使用预乘形式。straight-alpha 资源需要使用渲染器期望的转换或混合因子。在与伽马编码的 sRGB 中进行混合,而不是在线性光空间中进行混合,会导致错误的变暗和渐变,因为对编码值进行算术运算会产生不正确的结果。

人类颜色感知始于视锥细胞对重叠波长范围的响应。色彩空间将数值坐标映射到定义好的参考系统;没有所属空间的坐标是有歧义的。sRGB 定义原色、白点、传递函数和编码约定。在光照或插值前将 sRGB 转换为线性值,然后为显示进行编码。

HSL 和 HSV 是便于编辑的坐标,并非感知均匀的空间。色相、饱和度或亮度中相同的数值步长不会产生相同的感知步长。对于具有物理意义的混合渐变,应插值线性 RGB。对于围绕感知亮度设计的渐变,可以考虑 OKLab 等感知空间,并转换回目标色域;对超出色域的颜色进行裁剪或映射时,应明确决定处理方式。

品红是一种感知颜色,由长波和短波的视锥响应同时产生,而不存在对应的单一光谱波长。加法混合不会平均为一个波长;视觉系统会解释组合后的视锥信号。

在容器中运行 Playwright #

Playwright 需要浏览器二进制文件及其操作系统库。将官方镜像标签固定为项目中 @playwright/test 的版本:

dockerfile
FROM mcr.microsoft.com/playwright:v1.58.2-noble

WORKDIR /work
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN chown -R pwuser:pwuser /work
USER pwuser
CMD ["npx", "playwright", "test"]

官方镜像包含受支持的浏览器及其依赖。应同时更新镜像版本和软件包版本。使用 init 进程进行构建和运行:

sh
docker build -t example-playwright-tests .
docker run --rm --init --ipc=host example-playwright-tests

--init 会回收浏览器子进程。在 --ipc=host 下,Chromium 可以使用更大的共享内存区域;但只能在受信任的环境中使用。自定义 Node 基础镜像必须在构建镜像时安装浏览器依赖和选定的浏览器:

dockerfile
FROM node:22-bookworm
WORKDIR /work
COPY package.json package-lock.json ./
RUN npm ci && npx playwright install --with-deps chromium
COPY . .
CMD ["npx", "playwright", "test"]

不要在每次容器启动时下载浏览器。应将报告、跟踪文件、屏幕截图和视频作为 CI 工件持久化,因为失败往往取决于测试环境。

自动化虚拟化列表而不陷入无限循环 #

虚拟化列表为可见项目创建节点,并使用间隔元素表示完整的滚动范围。某个项目可能不在 DOM 中,但仍存在于数据集中,因此定位器必须驱动滚动并等待一次渲染周期。

限制搜索次数,并检测没有进展的情况:

ts
const scroller = page.locator('ul[role="listbox"] > div');
const option = page.getByRole("option", { name: targetAddress, exact: true });

for (let attempts = 0; attempts < 500; attempts += 1) {
  if (await option.count()) {
    await option.click();
    break;
  }

  const moved = await scroller.evaluate((element) => {
    const before = element.scrollTop;
    element.scrollBy(0, Math.max(1, element.clientHeight * 0.8));
    return element.scrollTop !== before;
  });
  if (!moved) throw new Error(`Option not found: ${targetAddress}`);
  await page.waitForTimeout(0);
}

waitForTimeout(0) 将控制权交还给浏览器事件循环,使滚动监听器或框架渲染能够更新虚拟化节点。在生产测试中,如果应用提供了数据 API 或语义化搜索控件,应优先使用它们;类似像素滚动的方式只是必须通过渲染界面测试的行为的后备方案。

评论

在 GitHub 上阅读或评论(发表评论需要登录)