Skip to content
Closed
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
6 changes: 3 additions & 3 deletions .bazelrc
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,9 @@ build:remote --auth_enabled=true
# is provided by the shared dev-infra package and targets k8 remote containers.
build:remote --crosstool_top=@npm//@angular/dev-infra-private/bazel/remote-execution/cpp:cc_toolchain_suite
build:remote --extra_toolchains=@npm//@angular/dev-infra-private/bazel/remote-execution/cpp:cc_toolchain
build:remote --extra_execution_platforms=@npm//@angular/dev-infra-private/bazel/remote-execution:platform
build:remote --host_platform=@npm//@angular/dev-infra-private/bazel/remote-execution:platform
build:remote --platforms=@npm//@angular/dev-infra-private/bazel/remote-execution:platform
build:remote --extra_execution_platforms=//tools:rbe_platform_with_increased_shared_memory
build:remote --host_platform=//tools:rbe_platform_with_increased_shared_memory
build:remote --platforms=//tools:rbe_platform_with_increased_shared_memory

################################
# --config=build-results #
Expand Down
22 changes: 22 additions & 0 deletions .github/workflows/approve-ssr-golden.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Approve SSR screenshot golden

on:
issue_comment:
types: [created]

jobs:
comment:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- run: npm install yarn -g
- run: yarn install --frozen-lockfile --non-interactive
- run: bazel run //src/universal-app:screenshot_test.accept
- name: Push back to pull request
run: |
git config --global user.name 'ng-github-robot'
git config --global user.email '[email protected]'
git commit -am "test: update kitchen-sink prerender screenshot golden"
git push
1 change: 1 addition & 0 deletions goldens/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
exports_files([
"size-test.yaml",
"kitchen-sink-prerendered.png",
])
Binary file added goldens/image-diff.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added goldens/kitchen-sink-prerendered.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added goldens/linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@
"@types/googlemaps": "^3.43.1",
"@types/youtube": "^0.0.42",
"core-js-bundle": "^3.8.2",
"fast-png": "^5.0.4",
"material-components-web": "12.0.0-canary.2952c6a76.0",
"pixelmatch": "^5.2.1",
"rxjs": "^6.5.3",
"rxjs-tslint-rules": "^4.33.1",
"systemjs": "0.19.43",
Expand Down Expand Up @@ -204,6 +206,7 @@
"parse5": "^6.0.1",
"postcss": "^8.2.1",
"protractor": "^7.0.0",
"puppeteer-core": "^10.0.0",
"reflect-metadata": "^0.1.3",
"requirejs": "^2.3.6",
"rollup": "~2.42.2",
Expand Down
34 changes: 29 additions & 5 deletions src/universal-app/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ load("//src/cdk-experimental:config.bzl", "CDK_EXPERIMENTAL_TARGETS")
load("//src/material:config.bzl", "MATERIAL_TARGETS")
load("//src/material-experimental:config.bzl", "MATERIAL_EXPERIMENTAL_TARGETS")
load("//tools:defaults.bzl", "ng_module", "sass_binary", "ts_library")
load(":ssr-screenshot-test.bzl", "ssr_screenshot_test")

package(default_visibility = ["//visibility:public"])

Expand Down Expand Up @@ -53,13 +54,36 @@ sass_binary(
],
)

nodejs_test(
name = "server_test",
data = [
"index.html",
prerenderStaticAssets = [
"index.html",
":theme_scss",
]

ts_library(
name = "ssr_screenshot_test_lib",
testonly = True,
srcs = ["ssr-screenshot-test-runner.ts"],
deps = [
":server",
":theme_scss",
"@npm//@bazel/runfiles",
"@npm//@types/node",
"@npm//@types/selenium-webdriver",
"@npm//fast-png",
"@npm//pixelmatch",
"@npm//puppeteer-core",
],
)

# Screenshot test that compares the pre-rendered universal-app against the golden.
ssr_screenshot_test(
name = "screenshot_test",
data = prerenderStaticAssets,
golden = "//goldens:kitchen-sink-prerendered.png",
)

nodejs_test(
name = "server_test",
data = [":server"] + prerenderStaticAssets,
entry_point = ":prerender.ts",
templated_args = [
# TODO(josephperrott): update dependency usages to no longer need bazel patch module resolver
Expand Down
2 changes: 1 addition & 1 deletion src/universal-app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
bottom: 100vh !important;
}
.cdk-overlay-backdrop::after {
content: 'OVERLAY ACTIVE';
content: 'OVERLAY ACTIVDDE';
background: lime;
}
</style>
Expand Down
4 changes: 3 additions & 1 deletion src/universal-app/kitchen-sink-mdc/kitchen-sink-mdc.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<h1>MDC implementation</h1>

<h2>Autocomplete</h2>
<mat-autocomplete>
<mat-option>Grace Hopper</mat-option>
<mat-option>Grace Hdopper</mat-option>
<mat-option>Anita Borg</mat-option>
<mat-option>Ada Lovelace</mat-option>
</mat-autocomplete>
Expand Down
9 changes: 7 additions & 2 deletions src/universal-app/kitchen-sink-mdc/kitchen-sink-mdc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ import {MatSelectModule} from '@angular/material-experimental/mdc-select';
import {MatPaginatorModule} from '@angular/material-experimental/mdc-paginator';

@Component({
template: `<button>Do the thing</button>`
template: `
<mat-dialog-content>
<button>MDC dialog</button>
</mat-dialog-content>
`,
})
export class TestEntryComponent {}

Expand All @@ -32,7 +36,8 @@ export class TestEntryComponent {}
})
export class KitchenSinkMdc {
constructor(dialog: MatDialog) {
dialog.open(TestEntryComponent);
// The MDC component is on the right, so we show the MDC dialog on the right too.
dialog.open(TestEntryComponent, {position: {top: '0', right: '0'}});
}
}

Expand Down
29 changes: 26 additions & 3 deletions src/universal-app/kitchen-sink-root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,33 @@ import {KitchenSinkModule} from './kitchen-sink/kitchen-sink';
@Component({
selector: 'kitchen-sink-root',
template: `
<h1>Kitchen sink app</h1>
<kitchen-sink></kitchen-sink>
<kitchen-sink-mdc></kitchen-sink-mdc>
<div class="kitchen-sink-row">
<kitchen-sink class="kitchen-sink"></kitchen-sink>
<kitchen-sink-mdc class="kitchen-sink"></kitchen-sink-mdc>
</div>
`,
styles: [`
/**
Align both components (the non-MDC and MDC kitchen-sinks) next to each other.
This reduces the overall height of the page and makes it easier to capture
in screenshot tests where browsers (even headless ones) seem to have a limit.
*/
.kitchen-sink-row {
display: flex;
flex-direction: row;
}

/** Add padding for the kitchen-sink components, and expand them equally in the row. */
.kitchen-sink {
flex: 1;
padding: 16px;
}

/** The first kitchen-sink should have a border to split up the two components visually. */
.kitchen-sink:first-child {
border-right: 2px solid grey;
}
`]
})
export class KitchenSinkRoot {
}
Expand Down
2 changes: 2 additions & 0 deletions src/universal-app/kitchen-sink/kitchen-sink.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
<h1>Standard implementation (non-MDC)</h1>

<h2>Autocomplete</h2>
<mat-autocomplete>
<mat-option>Grace Hopper</mat-option>
Expand Down
19 changes: 11 additions & 8 deletions src/universal-app/kitchen-sink/kitchen-sink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export class TableDataSource extends DataSource<any> {


@Component({
template: `<button>Do the thing</button>`
template: `<button>Non-MDC dialog</button>`
})
export class TestEntryComponent {}

Expand All @@ -77,15 +77,18 @@ export class KitchenSink {
virtualScrollData = Array(10000).fill(50);

constructor(
snackBar: MatSnackBar,
dialog: MatDialog,
viewportRuler: ViewportRuler,
focusMonitor: FocusMonitor,
elementRef: ElementRef<HTMLElement>,
bottomSheet: MatBottomSheet) {
snackBar: MatSnackBar,
dialog: MatDialog,
viewportRuler: ViewportRuler,
focusMonitor: FocusMonitor,
elementRef: ElementRef<HTMLElement>,
bottomSheet: MatBottomSheet) {
focusMonitor.focusVia(elementRef, 'program');
snackBar.open('Hello there');
dialog.open(TestEntryComponent);
// The non-MDC component is on the left, so we show the dialog on the left side too.
// Since the "overlay active" indicator is on the left top too, we shift the dialog
// to the right a little more (to avoid content overlapping).
dialog.open(TestEntryComponent, {position: {left: '30%', top: '0'}});
bottomSheet.open(TestEntryComponent);

// Do a sanity check on the viewport ruler.
Expand Down
10 changes: 6 additions & 4 deletions src/universal-app/prerender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,17 @@ import {KitchenSinkRootServerModuleNgFactory} from './kitchen-sink-root.ngfactor

// Resolve the path to the "index.html" through Bazel runfile resolution.
const indexHtmlPath = require.resolve('./index.html');
const outputPath = join(__dirname, 'index-prerendered.html');

const result = renderModuleFactory(
KitchenSinkRootServerModuleNgFactory,
{document: readFileSync(indexHtmlPath, 'utf-8')});

result
.then(content => {
const filename = join(__dirname, 'index-prerendered.html');

console.log('Inspect pre-rendered page here:');
console.log(`file://${filename}`);
writeFileSync(filename, content, 'utf-8');
console.log(`file://${outputPath}`);
writeFileSync(outputPath, content, 'utf-8');
console.log('Prerender done.');
})
// If rendering the module factory fails, print the error and exit the process
Expand All @@ -32,3 +31,6 @@ result
console.error(error);
process.exit(1);
});

// Export the output path in case this file is imported as part of a test.
export {outputPath};
133 changes: 133 additions & 0 deletions src/universal-app/ssr-screenshot-test-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Script that renders the `universal-app` on the server and takes a screenshot of the
* pre-rendered Angular application using a browser. The screenshot is then compared
* against a golden file `/goldens/kitchen-sink-prerendered.png`.
*
* Screenshot testing server-side rendered components prevents us from silently regressing
* with the visuals of pre-rendered components. Components should visually match as much
* as possible with the hydrated components to avoid unexpected flashing.
*/

import {runfiles} from '@bazel/runfiles';
import {readFileSync, writeFileSync} from 'fs';
import {decode, encode, PNGDataArray} from 'fast-png';
import {join} from 'path';
import {launch, Page} from 'puppeteer-core';

const pixelmatch = require('pixelmatch');

/**
* Metadata file generated by `rules_webtesting` for browser tests.
* The metadata provides configuration for launching the browser and
* necessary capabilities. See source for details:
* https://github.com/bazelbuild/rules_webtesting/blob/06023bb3/web/internal/metadata.bzl#L69-L82
*/
interface WebTestMetadata {
/**
* List of web test files for the current browser. We limit our type to Chromium which
* will be extracted at build time. More details on the properties:
* https://github.com/bazelbuild/rules_webtesting/blob/34c659ab3e78f41ebe6453bee6201a69aef90f56/go/metadata/web_test_files.go#L29.
*/
webTestFiles: {namedFiles: {CHROMIUM?: string}}[];
}

if (process.env['WEB_TEST_METADATA'] === undefined) {
console.error(`Test running outside of a "web_test" target. No browser found.`);
process.exit(1);
}

/** Web test metadata that has been registered as part of the Bazel `web_test`. */
const webTestMetadata: WebTestMetadata =
require(runfiles.resolve(process.env['WEB_TEST_METADATA']));

/** Path to Chromium extracted from the Bazel `web_test` metadata. */
const chromiumExecutableRootPath = webTestMetadata.webTestFiles?.[0].namedFiles.CHROMIUM;

/** Path to a directory where undeclared test artifacts can be stored. e.g. a diff file. */
const testOutputDirectory = process.env.TEST_UNDECLARED_OUTPUTS_DIR!;

/** Path for a screenshot diff image that can be written. */
const screenshotDiffPath = join(testOutputDirectory, 'image-diff.png');

/** Width of the browser for the screenshot. */
const screenshotBrowserWidth = 1920;

if (require.main === module) {
const args = process.argv.slice(2);
const goldenPath = runfiles.resolveWorkspaceRelative(args[0]);
const approveGolden = args[1] === 'true';

main(goldenPath, approveGolden).catch(e => {
console.error(e);
process.exit(1);
});
}

/** Entry point for the screenshot test runner. */
async function main(goldenPath: string, approveGolden: boolean) {
const outputPath = await renderKitchenSinkOnServer();
const browser = await launch({
executablePath: runfiles.resolve(chromiumExecutableRootPath!),
headless: true,
});

const page = await browser.newPage();
await page.goto(`file://${outputPath}`);
await updateBrowserViewportToMatchContent(page);
const currentScreenshotBuffer = await page.screenshot({encoding: 'binary'}) as Buffer;
await browser.close();

if (approveGolden) {
writeFileSync(goldenPath, currentScreenshotBuffer);
console.info('Golden screenshot updated.');
return;
}

const currentScreenshot = decode(currentScreenshotBuffer);
const goldenScreenshot = decode(readFileSync(goldenPath));
const diffImageData: PNGDataArray = new Uint8Array({length: currentScreenshot.data.length});
const numDiffPixels = pixelmatch(goldenScreenshot.data, currentScreenshot.data, diffImageData,
currentScreenshot.width, currentScreenshot.height);

console.error('diff perc', numDiffPixels / (currentScreenshot.width * currentScreenshot.height));

if (numDiffPixels !== 0) {
writeFileSync(screenshotDiffPath, encode({
data: diffImageData,
height: currentScreenshot.height,
width: currentScreenshot.width
}));

console.error(`Expected golden image to match. ${numDiffPixels} pixels do not match.`);
console.error(`Command to update the golden: yarn bazel run ${process.env.TEST_TARGET}.accept`);
console.error(`See diff: file://${screenshotDiffPath.replace(/\\/g, '/')}`);
process.exit(1);
}

console.info('Screenshot golden matches.');
}

/**
* Renders the kitchen-sink app on the server.
* @returns Path to the pre-rendered index HTML file.
*/
async function renderKitchenSinkOnServer(): Promise<string> {
const {outputPath} = await import('./prerender');
return outputPath;
}

/**
* Updates the browser viewport to match the `body` content so that everything
* becomes visible without any scrollbars. This is useful for screenshots as it
* allows Puppeteer to take full-page screenshots.
*/
async function updateBrowserViewportToMatchContent(page: Page) {
const bodyScrollHeight = await page.evaluate(() => document.body.scrollHeight);
// We use a hard-coded large width for the window, so that the screenshot does not become
// too large vertically. This also helps with potential webdriver screenshot issues where
// screenshots render incorrectly if the window height has been increased too much.
await page.setViewport({
width: screenshotBrowserWidth,
height: bodyScrollHeight,
});
}
Loading