mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
test: add preview regression gate
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
name: preview-regression
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: preview-regression-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect changes
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
outputs:
|
||||
preview: ${{ steps.filter.outputs.preview }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: dorny/paths-filter@v4
|
||||
id: filter
|
||||
with:
|
||||
token: ""
|
||||
filters: |
|
||||
preview:
|
||||
- "packages/core/**"
|
||||
- "packages/player/**"
|
||||
- "packages/studio/**"
|
||||
- "packages/cli/**"
|
||||
- "packages/producer/src/parity-harness.ts"
|
||||
- "packages/producer/src/parity-fixtures.ts"
|
||||
- "packages/producer/tests/parity/**"
|
||||
- "package.json"
|
||||
- "bun.lock"
|
||||
- ".github/workflows/preview-regression.yml"
|
||||
|
||||
preview-parity:
|
||||
name: Preview parity
|
||||
needs: changes
|
||||
if: needs.changes.outputs.preview == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build preview runtime
|
||||
run: bun run --cwd packages/core build:hyperframes-runtime
|
||||
|
||||
- name: Prepare parity fixtures
|
||||
run: bun run --cwd packages/producer parity:fixtures:ci
|
||||
|
||||
- name: Install ffmpeg
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends ffmpeg
|
||||
ffmpeg -version | head -n 1
|
||||
|
||||
- name: Set up Chrome
|
||||
id: setup-chrome
|
||||
uses: browser-actions/setup-chrome@v1
|
||||
with:
|
||||
chrome-version: stable
|
||||
|
||||
- name: Start parity fixture server
|
||||
run: |
|
||||
cd packages/producer/tests/parity/fixtures
|
||||
python3 -m http.server 4173 --bind 127.0.0.1 > /tmp/preview-parity-http.log 2>&1 &
|
||||
echo "$!" > /tmp/preview-parity-http.pid
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -fsS http://127.0.0.1:4173/minimal-wysiwyg.html >/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
cat /tmp/preview-parity-http.log
|
||||
exit 1
|
||||
|
||||
- name: Run preview parity check
|
||||
working-directory: packages/producer
|
||||
env:
|
||||
PUPPETEER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||
run: bun run parity:check:ci
|
||||
|
||||
- name: Upload parity artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: preview-parity-artifacts
|
||||
path: packages/producer/.debug/parity-harness-ci/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
|
||||
preview-regression:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, preview-parity]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Check results
|
||||
env:
|
||||
PREVIEW_FILTER_RESULT: ${{ needs.changes.outputs.preview }}
|
||||
PREVIEW_PARITY_RESULT: ${{ needs.preview-parity.result }}
|
||||
run: |
|
||||
{
|
||||
echo "## Preview regression gate"
|
||||
echo ""
|
||||
echo "- paths-filter \`preview\` matched: \`${PREVIEW_FILTER_RESULT}\`"
|
||||
echo "- preview-parity result: \`${PREVIEW_PARITY_RESULT}\`"
|
||||
echo ""
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [ "${PREVIEW_FILTER_RESULT}" != "true" ]; then
|
||||
echo "::notice title=Preview regression::SKIPPED — no preview/runtime changes. Auto-pass."
|
||||
echo "**Status:** SKIPPED (no preview/runtime changes — auto-pass)" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${PREVIEW_PARITY_RESULT}" != "success" ]; then
|
||||
echo "**Status:** FAILED" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Preview parity check failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "**Status:** PASSED" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -32,6 +32,7 @@ coverage/
|
||||
|
||||
# Producer regression test failures (generated debugging artifacts)
|
||||
packages/producer/tests/*/failures/
|
||||
packages/producer/tests/parity/fixtures/hyperframe.runtime.iife.js
|
||||
|
||||
# Player perf test results (generated each run, attached as CI artifact)
|
||||
packages/player/tests/perf/results/
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const sourceDir = dirname(fileURLToPath(import.meta.url));
|
||||
const producerRoot = resolve(sourceDir, "..");
|
||||
const repoRoot = resolve(producerRoot, "../..");
|
||||
const runtimePath = resolve(repoRoot, "packages/core/dist/hyperframe.runtime.iife.js");
|
||||
const fixturesDir = resolve(producerRoot, "tests/parity/fixtures");
|
||||
const fixtureRuntimePath = resolve(fixturesDir, "hyperframe.runtime.iife.js");
|
||||
|
||||
if (!existsSync(runtimePath)) {
|
||||
throw new Error(
|
||||
`Missing preview runtime at ${runtimePath}. Run "bun run --cwd packages/core build:hyperframes-runtime" first.`,
|
||||
);
|
||||
}
|
||||
|
||||
mkdirSync(fixturesDir, { recursive: true });
|
||||
copyFileSync(runtimePath, fixtureRuntimePath);
|
||||
|
||||
console.log(`[ParityFixtures] copied ${runtimePath} -> ${fixtureRuntimePath}`);
|
||||
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import process from "node:process";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import puppeteer, { type Page } from "puppeteer-core";
|
||||
import puppeteer, { type Browser, type Page } from "puppeteer-core";
|
||||
import { MEDIA_VISUAL_STYLE_PROPERTIES, quantizeTimeToFrame } from "./utils/parityContract.js";
|
||||
|
||||
type ParityHarnessOptions = {
|
||||
@@ -262,12 +262,41 @@ async function captureCheckpoint(
|
||||
if (emulateProducerSwap) {
|
||||
await emulateProducerVideoSwap(page);
|
||||
}
|
||||
await page.evaluate(
|
||||
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
|
||||
);
|
||||
await page.evaluate(`new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve();
|
||||
};
|
||||
window.setTimeout(finish, 100);
|
||||
requestAnimationFrame(() => requestAnimationFrame(finish));
|
||||
})`);
|
||||
return (await page.screenshot({ type: "png" })) as Buffer;
|
||||
}
|
||||
|
||||
async function captureParitySide(
|
||||
browser: Browser,
|
||||
url: string,
|
||||
checkpointSec: number,
|
||||
fps: number,
|
||||
emulateProducerSwap: boolean,
|
||||
): Promise<{ buffer: Buffer; styles: Record<string, unknown> }> {
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
await page.goto(url, {
|
||||
waitUntil: "load",
|
||||
timeout: 60_000,
|
||||
});
|
||||
await waitForParityReady(page);
|
||||
const buffer = await captureCheckpoint(page, checkpointSec, fps, emulateProducerSwap);
|
||||
const styles = await captureStyleSnapshot(page);
|
||||
return { buffer, styles };
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(data: Buffer): string {
|
||||
return createHash("sha256").update(data).digest("hex");
|
||||
}
|
||||
@@ -277,8 +306,11 @@ async function run(): Promise<void> {
|
||||
console.log(`[ParityHarness] options=${JSON.stringify(options)}`);
|
||||
ensureDir(options.artifactsDir);
|
||||
|
||||
const browserTarget = process.env.PUPPETEER_EXECUTABLE_PATH
|
||||
? { executablePath: process.env.PUPPETEER_EXECUTABLE_PATH }
|
||||
: { channel: "chrome" as const };
|
||||
const browser = await puppeteer.launch({
|
||||
channel: "chrome",
|
||||
...browserTarget,
|
||||
headless: true,
|
||||
defaultViewport: {
|
||||
width: options.width,
|
||||
@@ -301,21 +333,6 @@ async function run(): Promise<void> {
|
||||
});
|
||||
|
||||
try {
|
||||
const previewPage = await browser.newPage();
|
||||
const producerPage = await browser.newPage();
|
||||
|
||||
await Promise.all([
|
||||
previewPage.goto(options.previewUrl, {
|
||||
waitUntil: ["load", "networkidle2"],
|
||||
timeout: 60_000,
|
||||
}),
|
||||
producerPage.goto(options.producerUrl, {
|
||||
waitUntil: ["load", "networkidle2"],
|
||||
timeout: 60_000,
|
||||
}),
|
||||
]);
|
||||
await Promise.all([waitForParityReady(previewPage), waitForParityReady(producerPage)]);
|
||||
|
||||
let mismatches = 0;
|
||||
const results: Array<{
|
||||
checkpointSec: number;
|
||||
@@ -333,10 +350,22 @@ async function run(): Promise<void> {
|
||||
const checkpointKey = checkpointSec.toFixed(3).replace(/\./g, "_");
|
||||
const artifactDir = join(options.artifactsDir, `checkpoint_${checkpointKey}s`);
|
||||
ensureDir(artifactDir);
|
||||
const [previewBuffer, producerBuffer] = await Promise.all([
|
||||
captureCheckpoint(previewPage, checkpointSec, options.fps, false),
|
||||
captureCheckpoint(producerPage, checkpointSec, options.fps, options.emulateProducerSwap),
|
||||
]);
|
||||
const previewCapture = await captureParitySide(
|
||||
browser,
|
||||
options.previewUrl,
|
||||
checkpointSec,
|
||||
options.fps,
|
||||
false,
|
||||
);
|
||||
const producerCapture = await captureParitySide(
|
||||
browser,
|
||||
options.producerUrl,
|
||||
checkpointSec,
|
||||
options.fps,
|
||||
options.emulateProducerSwap,
|
||||
);
|
||||
const previewBuffer = previewCapture.buffer;
|
||||
const producerBuffer = producerCapture.buffer;
|
||||
const previewHash = sha256(previewBuffer);
|
||||
const producerHash = sha256(producerBuffer);
|
||||
const match = previewHash === producerHash;
|
||||
@@ -349,10 +378,8 @@ async function run(): Promise<void> {
|
||||
if (diffImagePath) {
|
||||
writeImageDiff(previewImagePath, producerImagePath, diffImagePath);
|
||||
}
|
||||
const [previewStyles, producerStyles] = await Promise.all([
|
||||
captureStyleSnapshot(previewPage),
|
||||
captureStyleSnapshot(producerPage),
|
||||
]);
|
||||
const previewStyles = previewCapture.styles;
|
||||
const producerStyles = producerCapture.styles;
|
||||
const previewStylesPath = join(artifactDir, "preview-styles.json");
|
||||
const producerStylesPath = join(artifactDir, "producer-styles.json");
|
||||
writeJson(previewStylesPath, previewStyles);
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Parity Fixture</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<script data-hyperframes-preview-runtime="1" src="hyperframe.runtime.iife.js"></script>
|
||||
<style>
|
||||
html,
|
||||
@@ -31,6 +30,24 @@
|
||||
height: 200px;
|
||||
border-radius: 0px;
|
||||
background: linear-gradient(135deg, #06b6d4, #6366f1);
|
||||
animation: parity-card 2s linear both paused;
|
||||
}
|
||||
|
||||
@keyframes parity-card {
|
||||
0% {
|
||||
border-radius: 0px;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
border-radius: 48px;
|
||||
transform: translateX(50px);
|
||||
}
|
||||
|
||||
100% {
|
||||
border-radius: 12px;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -45,24 +62,5 @@
|
||||
>
|
||||
<div id="animated-card" data-start="0"></div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#animated-card", {
|
||||
borderRadius: "48px",
|
||||
x: 50,
|
||||
duration: 1,
|
||||
ease: "none",
|
||||
});
|
||||
tl.to("#animated-card", {
|
||||
borderRadius: "12px",
|
||||
x: 0,
|
||||
duration: 1,
|
||||
ease: "none",
|
||||
});
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main-video"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user