Typescript-ify screenshot stitcher code in reporting (#20061) (#20149)

* typescript screenshot stitcher

* Throw an error if the data captured is not of the expected width and height.

* Import babel-core types

* Add babel-core types to x-pack package.json

* Dimensions => Rectangle
This commit is contained in:
Stacey Gammon 2018-06-22 10:53:00 -04:00 committed by GitHub
parent 17f483a432
commit 17ebccbf28
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 488 additions and 264 deletions

View file

@ -226,6 +226,8 @@
"@kbn/plugin-generator": "link:packages/kbn-plugin-generator",
"@kbn/test": "link:packages/kbn-test",
"@types/angular": "^1.6.45",
"@types/babel-core": "^6.25.5",
"@types/bluebird": "^3.1.1",
"@types/classnames": "^2.2.3",
"@types/eslint": "^4.16.2",
"@types/execa": "^0.9.0",

View file

@ -28,6 +28,7 @@
"@kbn/plugin-helpers": "link:../packages/kbn-plugin-helpers",
"@kbn/test": "link:../packages/kbn-test",
"@types/jest": "^22.2.3",
"@types/pngjs": "^3.3.1",
"abab": "^1.0.4",
"ansicolors": "0.3.2",
"aws-sdk": "2.2.33",
@ -82,6 +83,7 @@
"@elastic/numeral": "2.3.2",
"@kbn/datemath": "link:../packages/kbn-datemath",
"@kbn/ui-framework": "link:../packages/kbn-ui-framework",
"@samverschueren/stream-to-observable": "^0.3.0",
"@slack/client": "^4.2.2",
"angular-paging": "2.2.1",
"angular-resource": "1.4.9",
@ -154,7 +156,6 @@
"rison-node": "0.3.1",
"rxjs": "^6.1.0",
"semver": "5.1.0",
"@samverschueren/stream-to-observable": "^0.3.0",
"styled-components": "2.3.2",
"tar-fs": "1.13.0",
"tinycolor2": "1.3.0",

View file

@ -101,7 +101,7 @@ export class HeadlessChromiumDriver {
scale: 1
}
});
this._logger.debug(`captured screenshot clip ${JSON.stringify(screenshotClip)}`);
this._logger.debug(`Captured screenshot clip ${JSON.stringify(screenshotClip)}`);
return data;
}, this._logger);
}
@ -112,6 +112,7 @@ export class HeadlessChromiumDriver {
}
async setViewport({ width, height, zoom }) {
this._logger.debug(`Setting viewport to width: ${width}, height: ${height}, zoom: ${zoom}`);
const { Emulation } = this._client;
await Emulation.setDeviceMetricsOverride({

View file

@ -1,66 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import $streamToObservable from '@samverschueren/stream-to-observable';
import { PNG } from 'pngjs';
import * as Rx from 'rxjs';
import { mergeMap, reduce, tap, switchMap, toArray, map } from 'rxjs/operators';
// if we're only given one screenshot, and it matches the output dimensions
// we're going to skip the combination and just use it
const canUseFirstScreenshot = (screenshots, outputDimensions) => {
if (screenshots.length !== 1) {
return false;
}
const firstScreenshot = screenshots[0];
return firstScreenshot.dimensions.width === outputDimensions.width &&
firstScreenshot.dimensions.height === outputDimensions.height;
};
export function $combine(screenshots, outputDimensions, logger) {
if (screenshots.length === 0) {
return Rx.throwError('Unable to combine 0 screenshots');
}
if (canUseFirstScreenshot(screenshots, outputDimensions)) {
return Rx.of(screenshots[0].data);
}
const pngs$ = Rx.from(screenshots).pipe(
mergeMap(
({ data }) => {
const png = new PNG();
const buffer = Buffer.from(data, 'base64');
const parseAsObservable = Rx.bindNodeCallback(png.parse.bind(png));
return parseAsObservable(buffer);
},
({ dimensions }, png) => ({ dimensions, png })
)
);
const output$ = pngs$.pipe(
reduce(
(output, { dimensions, png }) => {
// Spitting out a lot of output to help debug https://github.com/elastic/kibana/issues/19563. Once that is
// fixed, this should probably get pared down.
logger.debug(`Output dimensions is ${JSON.stringify(outputDimensions)}`);
logger.debug(`Input png w: ${png.width} and h: ${png.height}`);
logger.debug(`Creating output png with ${JSON.stringify(dimensions)}`);
png.bitblt(output, 0, 0, dimensions.width, dimensions.height, dimensions.x, dimensions.y);
return output;
},
new PNG({ width: outputDimensions.width, height: outputDimensions.height })
)
);
return output$.pipe(
tap(png => png.pack()),
switchMap($streamToObservable),
toArray(),
map(chunks => Buffer.concat(chunks).toString('base64'))
);
}

View file

@ -0,0 +1,116 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
// No types found for this package. May want to investigate an alternative with types.
// @ts-ignore: implicit any for JS file
import $streamToObservable from '@samverschueren/stream-to-observable';
import { PNG } from 'pngjs';
import * as Rx from 'rxjs';
import { ObservableInput } from 'rxjs';
import { map, mergeMap, reduce, switchMap, tap, toArray } from 'rxjs/operators';
import { Logger, Screenshot, Size } from './types';
// if we're only given one screenshot, and it matches the given size
// we're going to skip the combination and just use it
const canUseFirstScreenshot = (
screenshots: Screenshot[],
size: { width: number; height: number }
) => {
if (screenshots.length !== 1) {
return false;
}
const firstScreenshot = screenshots[0];
return (
firstScreenshot.rectangle.width === size.width &&
firstScreenshot.rectangle.height === size.height
);
};
/**
* Combines the screenshot clips into a single screenshot of size `outputSize`.
* @param screenshots - Array of screenshots to combine
* @param outputSize - Final output size that the screenshots should match up with
* @param logger - logger for extra debug output
*/
export function $combine(
screenshots: Screenshot[],
outputSize: Size,
logger: Logger
): Rx.Observable<string> {
logger.debug(
`Combining screenshot clips into final, scaled output dimension of ${JSON.stringify(
outputSize
)}`
);
if (screenshots.length === 0) {
return Rx.throwError('Unable to combine 0 screenshots');
}
if (canUseFirstScreenshot(screenshots, outputSize)) {
return Rx.of(screenshots[0].data);
}
// Turn the screenshot data into actual PNGs
const pngs$ = Rx.from(screenshots).pipe(
mergeMap(
(screenshot: Screenshot): ObservableInput<PNG> => {
const png = new PNG();
const buffer = Buffer.from(screenshot.data, 'base64');
const parseAsObservable = Rx.bindNodeCallback(png.parse.bind(png));
return parseAsObservable(buffer);
},
(screenshot: Screenshot, png: PNG) => {
if (
png.width !== screenshot.rectangle.width ||
png.height !== screenshot.rectangle.height
) {
const errorMessage = `Screenshot captured with width:${
png.width
} and height: ${png.height}) is not of expected width: ${
screenshot.rectangle.width
} and height: ${screenshot.rectangle.height}`;
logger.error(errorMessage);
throw new Error(errorMessage);
}
return { screenshot, png };
}
)
);
const output$ = pngs$.pipe(
reduce((output: PNG, input: { screenshot: Screenshot; png: PNG }) => {
const { png, screenshot } = input;
// Spitting out a lot of output to help debug https://github.com/elastic/kibana/issues/19563. Once that is
// fixed, this should probably get pared down.
logger.debug(`Output dimensions is ${JSON.stringify(outputSize)}`);
logger.debug(`Input png w: ${png.width} and h: ${png.height}`);
logger.debug(
`Creating output png with ${JSON.stringify(screenshot.rectangle)}`
);
const { rectangle } = screenshot;
png.bitblt(
output,
0,
0,
rectangle.width,
rectangle.height,
rectangle.x,
rectangle.y
);
return output;
}, new PNG({ width: outputSize.width, height: outputSize.height }))
);
return output$.pipe(
tap(png => png.pack()),
switchMap<PNG, Buffer>($streamToObservable),
toArray(),
map((chunks: Buffer[]) => Buffer.concat(chunks).toString('base64'))
);
}

View file

@ -1,25 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as Rx from 'rxjs';
export function $getClips(dimensions, max) {
return Rx.from(function* () {
const columns = Math.ceil(dimensions.width / max) || 1;
const rows = Math.ceil(dimensions.height / max) || 1;
for (let row = 0; row < rows; ++row) {
for (let column = 0; column < columns; ++column) {
yield {
x: column * max + dimensions.x,
y: row * max + dimensions.y,
width: column === columns - 1 ? dimensions.width - (column * max) : max,
height: row === rows - 1 ? dimensions.height - (row * max) : max,
};
}
}
}());
}

View file

@ -1,101 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { toArray } from 'rxjs/operators';
import { $getClips } from './get_clips';
function getClipsTest(description, { dimensions, max }, { clips: expectedClips }) {
test(description, async () => {
const clips = await $getClips(dimensions, max).pipe(toArray()).toPromise();
expect(clips.length).toBe(expectedClips.length);
for (let i = 0; i < clips.length; ++i) {
expect(clips[i]).toEqual(expectedClips[i]);
}
});
}
getClipsTest(`creates one rect if 0, 0`,
{
dimensions: { x: 0, y: 0, height: 0, width: 0 },
max: 100,
},
{
clips: [{ x: 0, y: 0, height: 0, width: 0 }],
}
);
getClipsTest(`creates one rect if smaller than max`,
{
dimensions: { x: 0, y: 0, height: 99, width: 99 },
max: 100,
},
{
clips: [{ x: 0, y: 0, height: 99, width: 99 }],
}
);
getClipsTest(`create one rect if equal to max`,
{
dimensions: { x: 0, y: 0, height: 100, width: 100 },
max: 100,
},
{
clips: [{ x: 0, y: 0, height: 100, width: 100 }],
}
);
getClipsTest(`creates two rects if width is 1.5 * max`,
{
dimensions: { x: 0, y: 0, height: 100, width: 150 },
max: 100,
},
{
clips: [
{ x: 0, y: 0, height: 100, width: 100 },
{ x: 100, y: 0, height: 100, width: 50 }
],
}
);
getClipsTest(`creates two rects if height is 1.5 * max`,
{
dimensions: { x: 0, y: 0, height: 150, width: 100 },
max: 100,
},
{
clips: [
{ x: 0, y: 0, height: 100, width: 100 },
{ x: 0, y: 100, height: 50, width: 100 }
],
}
);
getClipsTest(`created four rects if height and width is 1.5 * max`,
{
dimensions: { x: 0, y: 0, height: 150, width: 150 },
max: 100,
},
{
clips: [
{ x: 0, y: 0, height: 100, width: 100 },
{ x: 100, y: 0, height: 100, width: 50 },
{ x: 0, y: 100, height: 50, width: 100 },
{ x: 100, y: 100, height: 50, width: 50 },
],
}
);
getClipsTest(`creates one rect if height and width is equal to max and theres a y equal to the max`,
{
dimensions: { x: 0, y: 100, height: 100, width: 100 },
max: 100,
},
{
clips: [
{ x: 0, y: 100, height: 100, width: 100 },
],
}
);

View file

@ -0,0 +1,113 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { toArray } from 'rxjs/operators';
import { $getClips } from './get_clips';
import { Rectangle } from './types';
function getClipsTest(
description: string,
input: { rectangle: Rectangle; max: number },
expectedClips: { clips: Rectangle[] }
) {
test(description, async () => {
const clips = await $getClips(input.rectangle, input.max)
.pipe(toArray())
.toPromise();
expect(clips.length).toBe(expectedClips.clips.length);
for (let i = 0; i < clips.length; ++i) {
expect(clips[i]).toEqual(expectedClips.clips[i]);
}
});
}
getClipsTest(
`creates one rect if 0, 0`,
{
max: 100,
rectangle: { x: 0, y: 0, height: 0, width: 0 },
},
{
clips: [{ x: 0, y: 0, height: 0, width: 0 }],
}
);
getClipsTest(
`creates one rect if smaller than max`,
{
max: 100,
rectangle: { x: 0, y: 0, height: 99, width: 99 },
},
{
clips: [{ x: 0, y: 0, height: 99, width: 99 }],
}
);
getClipsTest(
`create one rect if equal to max`,
{
max: 100,
rectangle: { x: 0, y: 0, height: 100, width: 100 },
},
{
clips: [{ x: 0, y: 0, height: 100, width: 100 }],
}
);
getClipsTest(
`creates two rects if width is 1.5 * max`,
{
max: 100,
rectangle: { x: 0, y: 0, height: 100, width: 150 },
},
{
clips: [
{ x: 0, y: 0, height: 100, width: 100 },
{ x: 100, y: 0, height: 100, width: 50 },
],
}
);
getClipsTest(
`creates two rects if height is 1.5 * max`,
{
max: 100,
rectangle: { x: 0, y: 0, height: 150, width: 100 },
},
{
clips: [
{ x: 0, y: 0, height: 100, width: 100 },
{ x: 0, y: 100, height: 50, width: 100 },
],
}
);
getClipsTest(
`created four rects if height and width is 1.5 * max`,
{
max: 100,
rectangle: { x: 0, y: 0, height: 150, width: 150 },
},
{
clips: [
{ x: 0, y: 0, height: 100, width: 100 },
{ x: 100, y: 0, height: 100, width: 50 },
{ x: 0, y: 100, height: 50, width: 100 },
{ x: 100, y: 100, height: 50, width: 50 },
],
}
);
getClipsTest(
`creates one rect if height and width is equal to max and theres a y equal to the max`,
{
max: 100,
rectangle: { x: 0, y: 100, height: 100, width: 100 },
},
{
clips: [{ x: 0, y: 100, height: 100, width: 100 }],
}
);

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as Rx from 'rxjs';
import { Rectangle } from './types';
/**
* Takes one large rectangle and breaks it down into an array of smaller rectangles,
* that if stitched together would create the original rectangle.
* @param largeRectangle - A big rectangle that might be broken down into smaller rectangles
* @param max - Maximum width or height any single clip should have
*/
export function $getClips(
largeRectangle: Rectangle,
max: number
): Rx.Observable<Rectangle> {
const rectanglesGenerator = function*(): IterableIterator<Rectangle> {
const columns = Math.ceil(largeRectangle.width / max) || 1;
const rows = Math.ceil(largeRectangle.height / max) || 1;
for (let row = 0; row < rows; ++row) {
for (let column = 0; column < columns; ++column) {
yield {
height: row === rows - 1 ? largeRectangle.height - row * max : max,
width:
column === columns - 1 ? largeRectangle.width - column * max : max,
x: column * max + largeRectangle.x,
y: row * max + largeRectangle.y,
};
}
}
};
return Rx.from(rectanglesGenerator());
}

View file

@ -1,55 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { toArray, map, mergeMap, switchMap } from 'rxjs/operators';
import { $getClips } from './get_clips';
import { $combine } from './combine';
const scaleRect = (rect, scale) => {
return {
x: rect.x * scale,
y: rect.y * scale,
width: rect.width * scale,
height: rect.height * scale,
};
};
export async function screenshotStitcher(outputClip, zoom, max, captureScreenshotFn, logger) {
// We have to divide the max by the zoom because we want to be limiting the resolution
// of the output screenshots, which is implicitly multiplied by the zoom, but we don't
// want the zoom to affect the clipping rects that we use
const screenshotClips$ = $getClips(outputClip, Math.ceil(max / zoom));
const screenshots$ = screenshotClips$.pipe(
mergeMap(
clip => captureScreenshotFn(clip),
(clip, data) => ({ clip, data }),
1
)
);
// when we take the screenshots we don't have to scale the rects
// but the PNGs don't know about the zoom, so we have to scale them
const screenshotPngDimensions$ = screenshots$.pipe(
map(
({ data, clip }) => ({
data,
dimensions: scaleRect({
x: clip.x - outputClip.x,
y: clip.y - outputClip.y,
width: clip.width,
height: clip.height,
}, zoom)
})
)
);
return screenshotPngDimensions$.pipe(
toArray(),
switchMap(screenshots => $combine(screenshots, scaleRect(outputClip, zoom), logger)),
)
.toPromise();
}

View file

@ -4,21 +4,29 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { promisify } from 'bluebird';
import fs from 'fs';
import path from 'path';
import { promisify } from 'bluebird';
import { screenshotStitcher } from './index';
const loggerMock = {
debug: () => {}
debug: () => {
return;
},
error: () => {
return;
},
warning: () => {
return;
},
};
const fsp = {
readFile: promisify(fs.readFile)
readFile: promisify(fs.readFile),
};
const readPngFixture = async (filename) => {
const readPngFixture = async (filename: string) => {
const buffer = await fsp.readFile(path.join(__dirname, 'fixtures', filename));
return buffer.toString('base64');
};
@ -49,10 +57,10 @@ const get4x4Checkerboard = () => {
test(`single screenshot`, async () => {
const clip = {
x: 0,
y: 0,
height: 1,
width: 1,
x: 0,
y: 0,
};
const fn = jest.fn();
@ -68,10 +76,10 @@ test(`single screenshot`, async () => {
test(`single screenshot, when zoom creates partial pixel we round up`, async () => {
const clip = {
x: 0,
y: 0,
height: 1,
width: 1,
x: 0,
y: 0,
};
const fn = jest.fn();
@ -87,10 +95,31 @@ test(`single screenshot, when zoom creates partial pixel we round up`, async ()
test(`two screenshots, no zoom`, async () => {
const clip = {
x: 0,
y: 0,
height: 1,
width: 2,
x: 0,
y: 0,
};
const fn = jest.fn();
fn.mockReturnValueOnce(getSingleWhitePixel());
fn.mockReturnValueOnce(getSingleBlackPixel());
const data = await screenshotStitcher(clip, 1, 1, fn, loggerMock);
expect(fn.mock.calls.length).toBe(2);
expect(fn.mock.calls[0][0]).toEqual({ x: 0, y: 0, width: 1, height: 1 });
expect(fn.mock.calls[1][0]).toEqual({ x: 1, y: 0, width: 1, height: 1 });
const expectedData = await get2x1Checkerboard();
expect(data).toEqual(expectedData);
});
test(`two screenshots, no zoom`, async () => {
const clip = {
height: 1,
width: 2,
x: 0,
y: 0,
};
const fn = jest.fn();
@ -108,10 +137,10 @@ test(`two screenshots, no zoom`, async () => {
test(`four screenshots, zoom`, async () => {
const clip = {
x: 0,
y: 0,
height: 2,
width: 2,
x: 0,
y: 0,
};
const fn = jest.fn();
@ -132,13 +161,12 @@ test(`four screenshots, zoom`, async () => {
expect(data).toEqual(expectedData);
});
test(`four screenshots, zoom and offset`, async () => {
const clip = {
x: 1,
y: 1,
height: 2,
width: 2,
x: 1,
y: 1,
};
const fn = jest.fn();

View file

@ -0,0 +1,95 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { map, mergeMap, switchMap, toArray } from 'rxjs/operators';
import { $combine } from './combine';
import { $getClips } from './get_clips';
import { Logger, Rectangle, Screenshot } from './types';
const scaleRect = (rect: Rectangle, scale: number): Rectangle => {
return {
height: rect.height * scale,
width: rect.width * scale,
x: rect.x * scale,
y: rect.y * scale,
};
};
/**
* Returns a stream of data that should be of the size outputClip.width * zoom x outputClip.height * zoom
* @param outputClip - The dimensions the final image should take.
* @param zoom - Determines the resolution want the final screenshot to take.
* @param maxDimensionPerClip - The maximimum dimension, in any direction (width or height) that we should allow per
* screenshot clip. If zoom is 10 and maxDimensionPerClip is anything less than or
* equal to 10, each clip taken, before being zoomed in, should be no bigger than 1 x 1.
* If zoom is 10 and maxDimensionPerClip is 20, then each clip taken before being zoomed in should be no bigger than 2 x 2.
* @param captureScreenshotFn - a function to take a screenshot from the page using the dimensions given. The data
* returned should have dimensions not of the clip passed in, but of the clip passed in multiplied by zoom.
* @param logger
*/
export async function screenshotStitcher(
outputClip: Rectangle,
zoom: number,
maxDimensionPerClip: number,
captureScreenshotFn: (rect: Rectangle) => Promise<string>,
logger: Logger
): Promise<string> {
// We have to divide the max by the zoom because we will be multiplying each clip's dimensions
// later by zoom, and we don't want those dimensions to end up larger than max.
const maxDimensionBeforeZoom = Math.ceil(maxDimensionPerClip / zoom);
const screenshotClips$ = $getClips(outputClip, maxDimensionBeforeZoom);
const screenshots$ = screenshotClips$.pipe(
mergeMap(
clip => captureScreenshotFn(clip),
(clip, data) => ({ clip, data }),
1
)
);
// when we take the screenshots we don't have to scale the rects
// but the PNGs don't know about the zoom, so we have to scale them
const screenshotPngRects$ = screenshots$.pipe(
map(({ data, clip }) => {
// At this point we don't care about the offset - the screenshots have been taken.
// We need to adjust the x & y values so they all are adjusted for the top-left most
// clip being at 0, 0.
const x = clip.x - outputClip.x;
const y = clip.y - outputClip.y;
const scaledScreenshotRects = scaleRect(
{
height: clip.height,
width: clip.width,
x,
y,
},
zoom
);
return {
data,
rectangle: scaledScreenshotRects,
};
})
);
const scaledOutputRects = scaleRect(outputClip, zoom);
return screenshotPngRects$
.pipe(
toArray(),
switchMap<Screenshot[], string>((screenshots: Screenshot[]) =>
$combine(
screenshots,
{
height: scaledOutputRects.height,
width: scaledOutputRects.width,
},
logger
)
)
)
.toPromise<string>();
}

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export interface Rectangle {
width: number;
height: number;
x: number;
y: number;
}
export interface Size {
width: number;
height: number;
}
export interface Screenshot {
data: string;
rectangle: Rectangle;
}
export interface Logger {
debug: (message: string) => void;
error: (message: string) => void;
warning: (message: string) => void;
}

View file

@ -174,6 +174,12 @@
dependencies:
"@types/retry" "*"
"@types/pngjs@^3.3.1":
version "3.3.1"
resolved "https://registry.yarnpkg.com/@types/pngjs/-/pngjs-3.3.1.tgz#47d97bd29dd6372856050e9e5e366517dd1ba2d8"
dependencies:
"@types/node" "*"
"@types/retry@*", "@types/retry@^0.10.2":
version "0.10.2"
resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.10.2.tgz#bd1740c4ad51966609b058803ee6874577848b37"

View file

@ -224,6 +224,49 @@
version "1.6.45"
resolved "https://registry.yarnpkg.com/@types/angular/-/angular-1.6.45.tgz#5b0b91a51d717f6fc816d59e1234d5292f33f7b9"
"@types/babel-core@^6.25.5":
version "6.25.5"
resolved "https://registry.yarnpkg.com/@types/babel-core/-/babel-core-6.25.5.tgz#7598b1287c2cb5a8e9150d60e4d4a8f2dbe29982"
dependencies:
"@types/babel-generator" "*"
"@types/babel-template" "*"
"@types/babel-traverse" "*"
"@types/babel-types" "*"
"@types/babylon" "*"
"@types/babel-generator@*":
version "6.25.2"
resolved "https://registry.yarnpkg.com/@types/babel-generator/-/babel-generator-6.25.2.tgz#fa13653ec2d34a4037be9c34dec32ae75bea04cc"
dependencies:
"@types/babel-types" "*"
"@types/babel-template@*":
version "6.25.1"
resolved "https://registry.yarnpkg.com/@types/babel-template/-/babel-template-6.25.1.tgz#03e23a893c16bab2ec00200ab51feccf488cae78"
dependencies:
"@types/babel-types" "*"
"@types/babylon" "*"
"@types/babel-traverse@*":
version "6.25.4"
resolved "https://registry.yarnpkg.com/@types/babel-traverse/-/babel-traverse-6.25.4.tgz#269af6a25c80419b635c8fa29ae42b0d5ce2418c"
dependencies:
"@types/babel-types" "*"
"@types/babel-types@*":
version "7.0.4"
resolved "https://registry.yarnpkg.com/@types/babel-types/-/babel-types-7.0.4.tgz#bfd5b0d0d1ba13e351dff65b6e52783b816826c8"
"@types/babylon@*":
version "6.16.3"
resolved "https://registry.yarnpkg.com/@types/babylon/-/babylon-6.16.3.tgz#c2937813a89fcb5e79a00062fc4a8b143e7237bb"
dependencies:
"@types/babel-types" "*"
"@types/bluebird@^3.1.1":
version "3.5.20"
resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.20.tgz#f6363172add6f4eabb8cada53ca9af2781e8d6a1"
"@types/classnames@^2.2.3":
version "2.2.3"
resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.3.tgz#3f0ff6873da793870e20a260cada55982f38a9e5"