Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

core(screenshots): align filmstrip to observed metrics #4965

Merged
merged 2 commits into from
Apr 13, 2018
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
116 changes: 63 additions & 53 deletions lighthouse-core/audits/screenshot-thumbnails.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
'use strict';

const Audit = require('./audit');
const TTFI = require('./first-interactive');
const TTCI = require('./consistently-interactive');
const LHError = require('../lib/errors');
const jpeg = require('jpeg-js');

const NUMBER_OF_THUMBNAILS = 10;
Expand All @@ -23,7 +22,7 @@ class ScreenshotThumbnails extends Audit {
informative: true,
description: 'Screenshot Thumbnails',
helpText: 'This is what the load of your site looked like.',
requiredArtifacts: ['traces'],
requiredArtifacts: ['traces', 'devtoolsLogs'],
};
}

Expand Down Expand Up @@ -67,62 +66,73 @@ class ScreenshotThumbnails extends Audit {
* @param {!Artifacts} artifacts
* @return {!AuditResult}
*/
static audit(artifacts, context) {
static async audit(artifacts, context) {
const trace = artifacts.traces[Audit.DEFAULT_PASS];
const cachedThumbnails = new Map();

return Promise.all([
artifacts.requestSpeedline(trace),
TTFI.audit(artifacts, context).catch(() => ({rawValue: 0})),
TTCI.audit(artifacts, context).catch(() => ({rawValue: 0})),
]).then(([speedline, ttfi, ttci]) => {
const thumbnails = [];
const analyzedFrames = speedline.frames.filter(frame => !frame.isProgressInterpolated());
const maxFrameTime =
speedline.complete ||
Math.max(...speedline.frames.map(frame => frame.getTimeStamp() - speedline.beginning));
// Find thumbnails to cover the full range of the trace (max of last visual change and time
// to interactive).
const timelineEnd = Math.max(maxFrameTime, ttfi.rawValue, ttci.rawValue);

for (let i = 1; i <= NUMBER_OF_THUMBNAILS; i++) {
const targetTimestamp = speedline.beginning + timelineEnd * i / NUMBER_OF_THUMBNAILS;

let frameForTimestamp = null;
if (i === NUMBER_OF_THUMBNAILS) {
frameForTimestamp = analyzedFrames[analyzedFrames.length - 1];
} else {
analyzedFrames.forEach(frame => {
if (frame.getTimeStamp() <= targetTimestamp) {
frameForTimestamp = frame;
}
});
}

const imageData = frameForTimestamp.getParsedImage();
const thumbnailImageData = ScreenshotThumbnails.scaleImageToThumbnail(imageData);
const base64Data =
cachedThumbnails.get(frameForTimestamp) ||
jpeg.encode(thumbnailImageData, 90).data.toString('base64');

cachedThumbnails.set(frameForTimestamp, base64Data);
thumbnails.push({
timing: Math.round(targetTimestamp - speedline.beginning),
timestamp: targetTimestamp * 1000,
data: base64Data,
const speedline = await artifacts.requestSpeedline(trace);

let minimumTimelineDuration = 0;
// Ensure thumbnails cover the full range of the trace (TTI can be later than visually complete)
if (context.settings.throttlingMethod !== 'simulate') {
const devtoolsLog = artifacts.devtoolsLogs[Audit.DEFAULT_PASS];
const metricComputationData = {trace, devtoolsLog, settings: context.settings};
const ttci = artifacts.requestConsistentlyInteractive(metricComputationData);
try {
minimumTimelineDuration = (await ttci).timing;
} catch (_) {
minimumTimelineDuration = 0;
}
}

const thumbnails = [];
const analyzedFrames = speedline.frames.filter(frame => !frame.isProgressInterpolated());
const maxFrameTime =
speedline.complete ||
Math.max(...speedline.frames.map(frame => frame.getTimeStamp() - speedline.beginning));
const timelineEnd = Math.max(maxFrameTime, minimumTimelineDuration);

if (!analyzedFrames.length || !Number.isFinite(timelineEnd)) {
throw new LHError(LHError.errors.INVALID_SPEEDLINE);
}

for (let i = 1; i <= NUMBER_OF_THUMBNAILS; i++) {
const targetTimestamp = speedline.beginning + timelineEnd * i / NUMBER_OF_THUMBNAILS;

let frameForTimestamp = null;
if (i === NUMBER_OF_THUMBNAILS) {
frameForTimestamp = analyzedFrames[analyzedFrames.length - 1];
} else {
analyzedFrames.forEach(frame => {
if (frame.getTimeStamp() <= targetTimestamp) {
frameForTimestamp = frame;
}
});
}

return {
score: 1,
rawValue: thumbnails.length > 0,
details: {
type: 'filmstrip',
scale: timelineEnd,
items: thumbnails,
},
};
});
const imageData = frameForTimestamp.getParsedImage();
const thumbnailImageData = ScreenshotThumbnails.scaleImageToThumbnail(imageData);
const base64Data =
cachedThumbnails.get(frameForTimestamp) ||
jpeg.encode(thumbnailImageData, 90).data.toString('base64');

cachedThumbnails.set(frameForTimestamp, base64Data);
thumbnails.push({
timing: Math.round(targetTimestamp - speedline.beginning),
timestamp: targetTimestamp * 1000,
data: base64Data,
});
}

return {
score: 1,
rawValue: thumbnails.length > 0,
details: {
type: 'filmstrip',
scale: timelineEnd,
items: thumbnails,
},
};
}
}

Expand Down
1 change: 1 addition & 0 deletions lighthouse-core/lib/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const ERRORS = {
NO_SPEEDLINE_FRAMES: {message: strings.didntCollectScreenshots},
SPEEDINDEX_OF_ZERO: {message: strings.didntCollectScreenshots},
NO_SCREENSHOTS: {message: strings.didntCollectScreenshots},
INVALID_SPEEDLINE: {message: strings.didntCollectScreenshots},

// Trace parsing errors
NO_TRACING_STARTED: {message: strings.badTraceRecording},
Expand Down
73 changes: 35 additions & 38 deletions lighthouse-core/test/audits/screenshot-thumbnails-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,45 +11,26 @@ const assert = require('assert');

const Runner = require('../../runner.js');
const ScreenshotThumbnailsAudit = require('../../audits/screenshot-thumbnails');
const TTFIAudit = require('../../audits/first-interactive');
const TTCIAudit = require('../../audits/consistently-interactive');
const pwaTrace = require('../fixtures/traces/progressive-app-m60.json');
const pwaDevtoolsLog = require('../fixtures/traces/progressive-app-m60.devtools.log.json');

/* eslint-env mocha */

describe('Screenshot thumbnails', () => {
let computedArtifacts;
let ttfiOrig;
let ttciOrig;
let ttfiReturn;
let ttciReturn;

before(() => {
computedArtifacts = Runner.instantiateComputedArtifacts();

// Monkey patch TTFI to simulate result
ttfiOrig = TTFIAudit.audit;
ttciOrig = TTCIAudit.audit;
TTFIAudit.audit = () => ttfiReturn || Promise.reject(new Error('oops!'));
TTCIAudit.audit = () => ttciReturn || Promise.reject(new Error('oops!'));
});

after(() => {
TTFIAudit.audit = ttfiOrig;
TTCIAudit.audit = ttciOrig;
});

beforeEach(() => {
ttfiReturn = null;
ttciReturn = null;
});

it('should extract thumbnails from a trace', () => {
const settings = {throttlingMethod: 'provided'};
const artifacts = Object.assign({
traces: {defaultPass: pwaTrace},
devtoolsLogs: {}, // empty devtools logs to test just thumbnails without TTI behavior
}, computedArtifacts);

return ScreenshotThumbnailsAudit.audit(artifacts).then(results => {
return ScreenshotThumbnailsAudit.audit(artifacts, {settings}).then(results => {
results.details.items.forEach((result, index) => {
const framePath = path.join(__dirname,
`../fixtures/traces/screenshots/progressive-app-frame-${index}.jpg`);
Expand All @@ -65,34 +46,50 @@ describe('Screenshot thumbnails', () => {
});
}).timeout(10000);

it('should scale the timeline to TTFI', () => {
it('should scale the timeline to TTCI when observed', () => {
const settings = {throttlingMethod: 'devtools'};
const artifacts = Object.assign({
traces: {defaultPass: pwaTrace},
devtoolsLogs: {defaultPass: pwaDevtoolsLog},
}, computedArtifacts);

ttfiReturn = Promise.resolve({rawValue: 4000});
return ScreenshotThumbnailsAudit.audit(artifacts).then(results => {
assert.equal(results.details.items[0].timing, 400);
assert.equal(results.details.items[9].timing, 4000);
const extrapolatedFrames = new Set(results.details.items.slice(3).map(f => f.data));
return ScreenshotThumbnailsAudit.audit(artifacts, {settings}).then(results => {
assert.equal(results.details.items[0].timing, 158);
assert.equal(results.details.items[9].timing, 1582);

// last 5 frames should be equal to the last real frame
const extrapolatedFrames = new Set(results.details.items.slice(5).map(f => f.data));
assert.ok(results.details.items[9].data.length > 100, 'did not have last frame');
assert.ok(extrapolatedFrames.size === 1, 'did not extrapolate last frame');
});
});

it('should scale the timeline to TTCI', () => {
it('should not scale the timeline to TTCI when simulate', () => {
const settings = {throttlingMethod: 'simulate'};
const artifacts = Object.assign({
traces: {defaultPass: pwaTrace},
}, computedArtifacts);
computedArtifacts.requestConsistentlyInteractive = () => ({timing: 20000});

ttfiReturn = Promise.resolve({rawValue: 8000});
ttciReturn = Promise.resolve({rawValue: 20000});
return ScreenshotThumbnailsAudit.audit(artifacts).then(results => {
assert.equal(results.details.items[0].timing, 2000);
assert.equal(results.details.items[9].timing, 20000);
const extrapolatedFrames = new Set(results.details.items.map(f => f.data));
assert.ok(results.details.items[9].data.length > 100, 'did not have last frame');
assert.ok(extrapolatedFrames.size === 1, 'did not extrapolate last frame');
return ScreenshotThumbnailsAudit.audit(artifacts, {settings}).then(results => {
assert.equal(results.details.items[0].timing, 82);
assert.equal(results.details.items[9].timing, 818);
});
});

it('should handle nonsense times', async () => {
const settings = {throttlingMethod: 'simulate'};
const artifacts = {
traces: {},
requestSpeedline: () => ({frames: [], complete: false, beginning: -1}),
requestConsistentlyInteractive: () => ({timing: NaN}),
};

try {
await ScreenshotThumbnailsAudit.audit(artifacts, {settings});
assert.fail('should have thrown');
} catch (err) {
assert.equal(err.message, 'INVALID_SPEEDLINE');
}
});
});