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
31 changes: 26 additions & 5 deletions clients/ember/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,16 @@ await vizzlyScreenshot('screenshot-name', {
user: 'admin'
},

// Fail test if visual diff detected (overrides --fail-on-diff flag)
failOnDiff: true
// Comparison tuning
threshold: 5,
minClusterSize: 10,

// Fail this test if a visual diff is detected
failOnDiff: true,

// Optional per-screenshot transport options
buildId: 'build_123',
requestTimeout: 60000
});
```

Expand All @@ -197,9 +205,21 @@ await vizzlyScreenshot('screenshot-name', {
| `height` | number | 720 | Viewport height for the screenshot |
| `selector` | string | null | CSS selector to capture specific element |
| `scope` | string | 'app' | What to capture: `'app'` (just #ember-testing), `'container'`, or `'page'` (full page including QUnit) |
| `fullPage` | boolean | false | Capture full scrollable content |
| `fullPage` | boolean | false | Capture full scrollable content for `scope: 'page'`; app, container, and selector captures are element-sized |
| `properties` | object | {} | Custom metadata attached to the screenshot |
| `failOnDiff` | boolean | null | Fail the test when visual diff is detected. `null` uses the `--fail-on-diff` CLI flag. |
| `threshold` | number | null | Vizzly comparison threshold. `null` uses the server config. |
| `minClusterSize` | number | null | Ignore connected diff clusters smaller than this size. `null` uses the server config. |
| `failOnDiff` | boolean | null | Fail the test when visual diff is detected. `null` uses the launcher/server setting; explicit `true` or `false` overrides it for this screenshot. |
| `buildId` | string | injected | Build ID override for this screenshot. Defaults to the build ID injected by `vizzly run`. |
| `requestTimeout` | number | null | HTTP request timeout in milliseconds for sending this screenshot to Vizzly. |

`failOnDiff` is resolved in this order: the per-screenshot option, the launcher
setting injected from `VIZZLY_FAIL_ON_DIFF` or `.vizzly/server.json`, then
non-failing mode.

Each screenshot also includes Vizzly metadata for grouping and comparison:
`browser`, `viewport_width`, `viewport_height`, `url`, and any custom
`properties` you provide.

The function automatically:
- Waits for Ember's `settled()` before capturing
Expand Down Expand Up @@ -248,6 +268,7 @@ For CI environments, ensure:

1. Browsers are installed: `pnpm exec playwright install chromium`
2. Vizzly token is set: `VIZZLY_TOKEN=your-token`
3. Tests run through the Vizzly CLI wrapper so cloud uploads are finalized

```yaml
# GitHub Actions example
Expand All @@ -257,7 +278,7 @@ For CI environments, ensure:
- name: Run Tests
env:
VIZZLY_TOKEN: ${{ secrets.VIZZLY_TOKEN }}
run: ember test
run: pnpm exec vizzly run "ember test"
```

### Failing on Visual Diffs
Expand Down
1 change: 1 addition & 0 deletions clients/ember/bin/vizzly-testem-launcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ async function main() {
browserInstance = await launchBrowser(browserType, testUrl, {
screenshotUrl,
failOnDiff,
buildId: process.env.VIZZLY_BUILD_ID || null,
playwrightOptions,
onPageCreated: page => {
setPage(page);
Expand Down
4 changes: 2 additions & 2 deletions clients/ember/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@
"bin",
"src",
"README.md",
"LICENSE",
"CHANGELOG.md"
"CHANGELOG.md",
"LICENSE"
],
"scripts": {
"test": "node --test --test-reporter=spec 'tests/unit/**/*.test.js'",
Expand Down
16 changes: 7 additions & 9 deletions clients/ember/src/launcher/browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,11 @@ function isCI() {
* Get default Chromium args for stability
* @returns {string[]}
*/
function getDefaultChromiumArgs() {
export function getDefaultChromiumArgs() {
let args = ['--no-sandbox', '--disable-setuid-sandbox'];

if (isCI()) {
args.push(
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-software-rasterizer',
'--disable-extensions'
);
args.push('--disable-dev-shm-usage', '--disable-extensions');
}

return args;
Expand All @@ -61,6 +56,7 @@ function getDefaultChromiumArgs() {
* @param {Object} options - Launch options
* @param {string} options.screenshotUrl - URL of the screenshot HTTP server
* @param {boolean} [options.failOnDiff] - Whether tests should fail on visual diffs
* @param {string|null} [options.buildId] - Build ID to attach to screenshots
* @param {Object} [options.playwrightOptions] - Playwright launch options (headless, slowMo, timeout, etc.)
* @param {Function} [options.onPageCreated] - Callback when page is created (before navigation)
* @param {Function} [options.onBrowserDisconnected] - Callback when browser disconnects unexpectedly
Expand All @@ -70,6 +66,7 @@ export async function launchBrowser(browserType, testUrl, options = {}) {
let {
screenshotUrl,
failOnDiff,
buildId = null,
playwrightOptions = {},
onPageCreated,
onBrowserDisconnected,
Expand Down Expand Up @@ -114,11 +111,12 @@ export async function launchBrowser(browserType, testUrl, options = {}) {

// Inject Vizzly config into page context BEFORE navigation
await page.addInitScript(
({ screenshotUrl, failOnDiff }) => {
({ screenshotUrl, failOnDiff, buildId }) => {
window.__VIZZLY_SCREENSHOT_URL__ = screenshotUrl;
window.__VIZZLY_FAIL_ON_DIFF__ = failOnDiff;
window.__VIZZLY_BUILD_ID__ = buildId;
},
{ screenshotUrl, failOnDiff }
{ screenshotUrl, failOnDiff, buildId }
);

// Call onPageCreated callback BEFORE navigation
Expand Down
66 changes: 52 additions & 14 deletions clients/ember/src/launcher/screenshot-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,47 +111,54 @@ export function clearServerInfoCache() {
* @param {string} name - Screenshot name
* @param {Buffer} imageBuffer - PNG image data
* @param {Object} properties - Screenshot metadata
* @param {Object} [options] - Forwarding options
* @param {string|null} [options.buildId] - Build ID to attach to screenshot
* @param {number|null} [options.requestTimeout] - HTTP request timeout in milliseconds
* @returns {Promise<Object>} Response from TDD server
*/
async function forwardToVizzly(name, imageBuffer, properties = {}) {
async function forwardToVizzly(
name,
imageBuffer,
properties = {},
{ buildId = null, requestTimeout = null } = {}
) {
let serverInfo = autoDiscoverTddServer();

if (!serverInfo) {
// Check for cloud mode via environment
if (process.env.VIZZLY_TOKEN) {
// In cloud mode, we'd queue for upload
// For MVP, return success and let TDD server handle cloud forwarding
return { success: true, mode: 'cloud', queued: true };
}

throw new Error(
'No Vizzly server found. Run `vizzly tdd start` first, or set VIZZLY_TOKEN for cloud mode.'
'No Vizzly server found. Run `vizzly tdd start` first, or run tests through `vizzly run` for cloud uploads.'
);
}

let tddServerUrl = serverInfo.url;

let payload = {
...(buildId ? { buildId } : {}),
name,
image: imageBuffer.toString('base64'),
properties: {
framework: 'ember',
...properties,
framework: 'ember',
},
};

// Use node:http directly with Connection: close to prevent keep-alive hangs
let result = await httpPost(`${tddServerUrl}/screenshot`, payload);
let result = await httpPost(
`${tddServerUrl}/screenshot`,
payload,
requestTimeout
);
return result;
}

/**
* Make HTTP POST request without keep-alive (prevents process hang on shutdown)
* @param {string} url - Target URL
* @param {Object} data - JSON payload
* @param {number|null} [timeoutMs] - Request timeout in milliseconds
* @returns {Promise<Object>} Parsed JSON response
*/
function httpPost(url, data) {
function httpPost(url, data, timeoutMs = null) {
return new Promise((resolve, reject) => {
let parsedUrl = new URL(url);
let isHttps = parsedUrl.protocol === 'https:';
Expand Down Expand Up @@ -197,6 +204,14 @@ function httpPost(url, data) {
});

req.on('error', reject);

if (timeoutMs) {
req.setTimeout(timeoutMs, () => {
req.destroy();
reject(new Error('Request timeout'));
});
}

req.write(body);
req.end();
});
Expand All @@ -216,7 +231,15 @@ async function handleScreenshot(req, res) {

req.on('end', async () => {
try {
let { name, selector, fullPage, properties } = JSON.parse(body);
let {
buildId,
name,
selector,
fullPage,
properties,
viewport,
requestTimeout,
} = JSON.parse(body);

if (!name) {
res.writeHead(400, { 'Content-Type': 'application/json' });
Expand All @@ -239,8 +262,20 @@ async function handleScreenshot(req, res) {
};

let imageBuffer;
if (
viewport?.width &&
viewport?.height &&
typeof pageRef.setViewportSize === 'function'
) {
await pageRef.setViewportSize({
width: viewport.width,
height: viewport.height,
});
}

if (selector) {
delete screenshotOptions.fullPage;

// Capture specific element
let element = pageRef.locator(selector).first();
let elementHandle = await element.elementHandle();
Expand All @@ -258,7 +293,10 @@ async function handleScreenshot(req, res) {
}

// Forward to Vizzly TDD server
let result = await forwardToVizzly(name, imageBuffer, properties);
let result = await forwardToVizzly(name, imageBuffer, properties, {
buildId,
requestTimeout,
});

res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
Expand Down
84 changes: 69 additions & 15 deletions clients/ember/src/test-support/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,12 +182,16 @@ function shouldFailOnDiff() {
* @param {string} name - Unique name for this screenshot
* @param {Object} [options] - Screenshot options
* @param {string} [options.selector] - CSS selector to capture specific element within the app
* @param {boolean} [options.fullPage=false] - Capture full scrollable content
* @param {boolean} [options.fullPage=false] - Capture full scrollable content for page-scoped screenshots
* @param {number} [options.width=1280] - Viewport width for the screenshot
* @param {number} [options.height=720] - Viewport height for the screenshot
* @param {string} [options.scope='app'] - What to capture: 'app' (default), 'container', or 'page'
* @param {Object} [options.properties] - Additional metadata for the screenshot
* @param {number} [options.threshold] - Vizzly comparison threshold. Omitted values use server config.
* @param {number} [options.minClusterSize] - Ignore connected diff clusters smaller than this size. Omitted values use server config.
* @param {boolean} [options.failOnDiff] - Fail the test if visual diff is detected (overrides env var)
* @param {string} [options.buildId] - Build ID override for this screenshot
* @param {number} [options.requestTimeout] - HTTP request timeout in milliseconds
* @returns {Promise<Object>} Screenshot result from Vizzly server
*
* @example
Expand All @@ -214,7 +218,11 @@ export async function vizzlyScreenshot(name, options = {}) {
height = 720,
scope = 'app',
properties = {},
threshold = null,
minClusterSize = null,
failOnDiff = null, // null means use env var, true/false overrides
buildId = null,
requestTimeout = null,
} = options;

// Get screenshot URL injected by the launcher
Expand All @@ -234,13 +242,6 @@ export async function vizzlyScreenshot(name, options = {}) {
await settled();
}

// Prepare testing container for screenshot (expand to full size)
let cleanup = prepareTestingContainer(width, height, fullPage);

// Force a repaint to ensure styles are applied
// eslint-disable-next-line no-unused-expressions
document.body.offsetHeight;

// Determine what selector to pass to Playwright
let captureSelector = null;

Expand All @@ -259,25 +260,78 @@ export async function vizzlyScreenshot(name, options = {}) {
}
// scope === 'page' means captureSelector stays null (full page)

let effectiveFullPage = captureSelector ? false : fullPage;

// Prepare testing container for screenshot (expand to full size)
let cleanup = prepareTestingContainer(width, height, effectiveFullPage);

// Force a repaint to ensure styles are applied
// eslint-disable-next-line no-unused-expressions
document.body.offsetHeight;

let customViewport = properties.viewport;
let customViewportWidth = properties.viewport_width;
let customViewportHeight = properties.viewport_height;
let screenshotProperties = {
...properties,
framework: 'ember',
browser: detectBrowser(),
viewport_width: width,
viewport_height: height,
url: window.location.href,
};

if (customViewport !== undefined) {
screenshotProperties.viewport = customViewport;
}

if (customViewportWidth !== undefined) {
screenshotProperties.viewport_width = customViewportWidth;
}

if (customViewportHeight !== undefined) {
screenshotProperties.viewport_height = customViewportHeight;
}

if (threshold !== null) {
screenshotProperties.threshold = threshold;
}

if (minClusterSize !== null) {
screenshotProperties.minClusterSize = minClusterSize;
}

if (!captureSelector && effectiveFullPage !== null) {
screenshotProperties.fullPage = effectiveFullPage;
}

// Build request payload
let payload = {
buildId: buildId || window.__VIZZLY_BUILD_ID__ || null,
name,
selector: captureSelector,
fullPage,
properties: {
browser: detectBrowser(),
viewport_width: width,
viewport_height: height,
url: window.location.href,
...properties,
fullPage: effectiveFullPage,
viewport: {
width,
height,
},
properties: screenshotProperties,
requestTimeout,
};

try {
let signal =
requestTimeout &&
typeof AbortSignal !== 'undefined' &&
typeof AbortSignal.timeout === 'function'
? AbortSignal.timeout(requestTimeout)
: undefined;

// Send screenshot request to server
let response = await fetch(`${screenshotUrl}/screenshot`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
...(signal ? { signal } : {}),
body: JSON.stringify(payload),
});

Expand Down
Loading