polymer-analyzer
Version:
Static analysis for Web Components
938 lines (936 loc) • 53.8 kB
JavaScript
"use strict";
/**
* @license
* Copyright (c) 2016 The Polymer Project Authors. All rights reserved.
* This code may only be used under the BSD style license found at
* http://polymer.github.io/LICENSE.txt
* The complete set of authors may be found at
* http://polymer.github.io/AUTHORS.txt
* The complete set of contributors may be found at
* http://polymer.github.io/CONTRIBUTORS.txt
* Code distributed by Google as part of the polymer project is also
* subject to an additional IP rights grant found at
* http://polymer.github.io/PATENTS.txt
*/
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
/// <reference path="../../../node_modules/@types/mocha/index.d.ts" />
const chai_1 = require("chai");
const clone = require("clone");
const path = require("path");
const analyzer_1 = require("../../core/analyzer");
const utils_1 = require("../../core/utils");
const html_document_1 = require("../../html/html-document");
const html_parser_1 = require("../../html/html-parser");
const javascript_document_1 = require("../../javascript/javascript-document");
const model_1 = require("../../model/model");
const fs_url_loader_1 = require("../../url-loader/fs-url-loader");
const overlay_loader_1 = require("../../url-loader/overlay-loader");
const test_utils_1 = require("../test-utils");
const chaiAsPromised = require("chai-as-promised");
const chaiSubset = require("chai-subset");
const stripIndent = require("strip-indent");
chai_1.use(chaiSubset);
chai_1.use(chaiAsPromised);
function getOnly(iter) {
const arr = Array.from(iter);
chai_1.assert.equal(arr.length, 1);
return arr[0];
}
const testDir = path.join(__dirname, '..');
suite('Analyzer', () => {
let analyzer;
let inMemoryOverlay;
let underliner;
function analyzeDocument(url, localAnalyzer) {
return __awaiter(this, void 0, void 0, function* () {
localAnalyzer = localAnalyzer || analyzer;
const document = (yield localAnalyzer.analyze([url])).getDocument(url);
chai_1.assert.instanceOf(document, model_1.Document);
return document;
});
}
;
setup(() => {
const underlyingUrlLoader = new fs_url_loader_1.FSUrlLoader(testDir);
inMemoryOverlay = new overlay_loader_1.InMemoryOverlayUrlLoader(underlyingUrlLoader);
analyzer = new analyzer_1.Analyzer({ urlLoader: inMemoryOverlay });
underliner = new test_utils_1.CodeUnderliner(inMemoryOverlay);
});
test('canLoad delegates to the urlLoader canLoad method', () => {
chai_1.assert.isTrue(analyzer.canLoad('/'), '/');
chai_1.assert.isTrue(analyzer.canLoad('/path'), '/path');
chai_1.assert.isFalse(analyzer.canLoad('../path'), '../path');
chai_1.assert.isFalse(analyzer.canLoad('http://host/'), 'http://host/');
chai_1.assert.isFalse(analyzer.canLoad('http://host/path'), 'http://host/path');
});
suite('canResolveUrl()', () => {
test('canResolveUrl defaults to not resolving external urls', () => {
chai_1.assert.isTrue(analyzer.canResolveUrl('/path'), '/path');
chai_1.assert.isTrue(analyzer.canResolveUrl('../path'), '../path');
chai_1.assert.isFalse(analyzer.canResolveUrl('http://host'), 'http://host');
chai_1.assert.isFalse(analyzer.canResolveUrl('http://host/path'), 'http://host/path');
});
});
suite('analyze()', () => {
test('analyzes a document with an inline Polymer element feature', () => __awaiter(this, void 0, void 0, function* () {
const document = yield analyzeDocument('static/analysis/simple/simple-element.html');
const elements = Array.from(document.getFeatures({ kind: 'element', imported: false }));
chai_1.assert.deepEqual(elements.map((e) => e.tagName), ['simple-element']);
}));
test('analyzes a document with an external Polymer element feature', () => __awaiter(this, void 0, void 0, function* () {
const document = yield analyzeDocument('static/analysis/separate-js/element.html');
const elements = Array.from(document.getFeatures({ kind: 'element', imported: true }));
chai_1.assert.deepEqual(elements.map((e) => e.tagName), ['my-element']);
}));
test('gets source ranges of documents correct', () => __awaiter(this, void 0, void 0, function* () {
const document = yield analyzeDocument('static/dependencies/root.html');
chai_1.assert.deepEqual(yield underliner.underline(document.sourceRange), `
<link rel="import" href="inline-only.html">
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
<link rel="import" href="leaf.html">
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
<link rel="import" href="inline-and-imports.html">
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
<link rel="import" href="subfolder/in-folder.html">
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
<link rel="lazy-import" href="lazy.html">
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
`);
}));
test('analyzes inline scripts correctly', () => __awaiter(this, void 0, void 0, function* () {
const document = yield analyzeDocument('static/inline-documents/inline-documents.html');
const jsDocuments = document.getFeatures({ kind: 'js-document' });
chai_1.assert.equal(jsDocuments.size, 1);
const jsDocument = getOnly(jsDocuments);
chai_1.assert.isObject(jsDocument.astNode);
chai_1.assert.equal(jsDocument.astNode.tagName, 'script');
chai_1.assert.deepEqual(yield underliner.underline(jsDocument.sourceRange), `
<script>
~
console.log('hi');
~~~~~~~~~~~~~~~~~~~~~~
</script>
~~`);
}));
test('analyzes inline styles correctly', () => __awaiter(this, void 0, void 0, function* () {
const document = yield analyzeDocument('static/inline-documents/inline-documents.html');
const cssDocuments = document.getFeatures({ kind: 'css-document' });
const cssDocument = getOnly(cssDocuments);
chai_1.assert.isObject(cssDocument.astNode);
chai_1.assert.equal(cssDocument.astNode.tagName, 'style');
chai_1.assert.deepEqual(yield underliner.underline(cssDocument.sourceRange), `
<style>
~
body {
~~~~~~~~~~
color: red;
~~~~~~~~~~~~~~~~~
}
~~~~~
</style>
~~`);
}));
test('analyzes a document with an import', () => __awaiter(this, void 0, void 0, function* () {
const document = yield analyzeDocument('static/analysis/behaviors/behavior.html');
const behaviors = Array.from(document.getFeatures({ kind: 'behavior', imported: true }));
chai_1.assert.deepEqual(behaviors.map((b) => b.className), ['MyNamespace.SubBehavior', 'MyNamespace.SimpleBehavior']);
}));
test('creates "missing behavior" warnings on imported documents without elements', () => __awaiter(this, void 0, void 0, function* () {
const document = yield analyzeDocument('static/chained-missing-behavior/index.html');
const chainedDocument = getOnly(document.getFeatures({
kind: 'document',
id: 'static/chained-missing-behavior/chained.html',
imported: true
}));
const expectedWarning = {
code: 'unknown-polymer-behavior',
message: 'Unable to resolve behavior `NotFoundBehavior`. Did you import it? Is it annotated with @polymerBehavior?',
severity: 1,
sourceRange: {
end: { column: 55, line: 2 },
start: { column: 39, line: 2 },
file: 'static/chained-missing-behavior/chained.html'
},
};
chai_1.assert.deepEqual(document.getWarnings({ imported: false }), []);
chai_1.assert.deepEqual(document.getWarnings({ imported: true }).map((w) => w.toJSON()), [expectedWarning]);
chai_1.assert.deepEqual(chainedDocument.getWarnings({ imported: false })
.map((w) => w.toJSON()), [expectedWarning]);
}));
test('an inline document can find features from its container document', () => __awaiter(this, void 0, void 0, function* () {
const document = yield analyzeDocument('static/analysis/behaviors/behavior.html');
const localDocuments = document.getFeatures({ kind: 'document', imported: false });
chai_1.assert.equal(localDocuments.size, 2); // behavior.html and its inline
const allDocuments = document.getFeatures({ kind: 'document', imported: true });
chai_1.assert.equal(allDocuments.size, 4);
const inlineDocuments = Array.from(document.getFeatures({ imported: false }))
.filter((d) => d instanceof model_1.Document && d.isInline);
chai_1.assert.equal(inlineDocuments.length, 1);
// This is the main purpose of the test: get a feature from
// the inline
// document that's imported by the container document
const behaviorJsDocument = inlineDocuments[0];
const subBehavior = getOnly(behaviorJsDocument.getFeatures({
kind: 'behavior',
id: 'MyNamespace.SubBehavior',
imported: true
}));
chai_1.assert.equal(subBehavior.className, 'MyNamespace.SubBehavior');
}));
test('an inline script can find features from its container document', () => __awaiter(this, void 0, void 0, function* () {
const document = yield analyzeDocument('static/script-tags/inline/test-element.html');
const inlineDocuments = Array
.from(document.getFeatures({ kind: 'document', imported: false }))
.filter((d) => d.isInline);
chai_1.assert.equal(inlineDocuments.length, 1);
const inlineJsDocument = inlineDocuments[0];
// The inline document can find the container's imported
// features
const subBehavior = getOnly(inlineJsDocument.getFeatures({ kind: 'behavior', id: 'TestBehavior', imported: true }));
chai_1.assert.equal(subBehavior.className, 'TestBehavior');
}));
test('an external script can find features from its container document', () => __awaiter(this, void 0, void 0, function* () {
const document = yield analyzeDocument('static/script-tags/external/test-element.html');
const htmlScriptTags = Array.from(document.getFeatures({ kind: 'html-script', imported: false }));
chai_1.assert.equal(htmlScriptTags.length, 1);
const htmlScriptTag = htmlScriptTags[0];
const scriptDocument = htmlScriptTag.document;
// The inline document can find the container's imported
// features
const subBehavior = getOnly(scriptDocument.getFeatures({ kind: 'behavior', id: 'TestBehavior', imported: true }));
chai_1.assert.equal(subBehavior.className, 'TestBehavior');
}));
// This test is nearly identical to the previous, but covers a different
// issue.
// PolymerElement must find behaviors while resolving, and if inline
// documents don't add a document feature for their container until after
// resolution, then the element can't find them and throws.
test('an inline document can find behaviors from its container document', () => __awaiter(this, void 0, void 0, function* () {
const document = yield analyzeDocument('static/analysis/behaviors/elementdir/element.html');
const documents = document.getFeatures({ kind: 'document', imported: false });
chai_1.assert.equal(documents.size, 2);
const inlineDocuments = Array.from(documents).filter((d) => d instanceof model_1.Document && d.isInline);
chai_1.assert.equal(inlineDocuments.length, 1);
// This is the main purpose of the test: get a feature
// from the inline
// document that's imported by the container document
const behaviorJsDocument = inlineDocuments[0];
const subBehavior = getOnly(behaviorJsDocument.getFeatures({
kind: 'behavior',
id: 'MyNamespace.SubBehavior',
imported: true
}));
chai_1.assert.equal(subBehavior.className, 'MyNamespace.SubBehavior');
}));
test('returns a Document with warnings for malformed files', () => __awaiter(this, void 0, void 0, function* () {
const document = yield analyzeDocument('static/malformed.html');
chai_1.assert(document.getWarnings({ imported: false }).length >= 1);
}));
test('analyzes transitive dependencies', () => __awaiter(this, void 0, void 0, function* () {
const root = yield analyzeDocument('static/dependencies/root.html');
// If we ask for documents we get every document in evaluation order.
const strictlyReachableDocuments = [
['static/dependencies/root.html', 'html', false],
['static/dependencies/inline-only.html', 'html', false],
['static/dependencies/inline-only.html', 'js', true],
['static/dependencies/inline-only.html', 'css', true],
['static/dependencies/leaf.html', 'html', false],
['static/dependencies/inline-and-imports.html', 'html', false],
['static/dependencies/inline-and-imports.html', 'js', true],
['static/dependencies/subfolder/in-folder.html', 'html', false],
['static/dependencies/subfolder/subfolder-sibling.html', 'html', false],
['static/dependencies/inline-and-imports.html', 'css', true],
];
// If we ask for documents we get every document in
// evaluation order.
chai_1.assert.deepEqual(Array
.from(root.getFeatures({ kind: 'document', imported: true, noLazyImports: true }))
.map((d) => [d.url, d.parsedDocument.type, d.isInline]), strictlyReachableDocuments);
chai_1.assert.deepEqual(Array.from(root.getFeatures({ kind: 'document', imported: true }))
.map((d) => [d.url, d.parsedDocument.type, d.isInline]), strictlyReachableDocuments.concat([['static/dependencies/lazy.html', 'html', false]]));
// If we ask for imports we get the import statements in evaluation order.
// Unlike documents, we can have duplicates here because imports exist in
// distinct places in their containing docs.
chai_1.assert.deepEqual(Array.from(root.getFeatures({ kind: 'import', imported: true }))
.map((d) => d.url), [
'static/dependencies/inline-only.html',
'static/dependencies/leaf.html',
'static/dependencies/inline-and-imports.html',
'static/dependencies/subfolder/in-folder.html',
'static/dependencies/subfolder/subfolder-sibling.html',
'static/dependencies/subfolder/in-folder.html',
'static/dependencies/lazy.html',
]);
const inlineOnly = getOnly(root.getFeatures({
kind: 'document',
id: 'static/dependencies/inline-only.html',
imported: true
}));
chai_1.assert.deepEqual(Array
.from(inlineOnly.getFeatures({ kind: 'document', imported: true }))
.map((d) => d.parsedDocument.type), ['html', 'js', 'css']);
const leaf = getOnly(root.getFeatures({
kind: 'document',
id: 'static/dependencies/leaf.html',
imported: true
}));
chai_1.assert.deepEqual(Array.from(leaf.getFeatures({ kind: 'document', imported: true })), [leaf]);
const inlineAndImports = getOnly(root.getFeatures({
kind: 'document',
id: 'static/dependencies/inline-and-imports.html',
imported: true
}));
chai_1.assert.deepEqual(Array
.from(inlineAndImports.getFeatures({ kind: 'document', imported: true }))
.map((d) => d.parsedDocument.type), ['html', 'js', 'html', 'html', 'css']);
const inFolder = getOnly(root.getFeatures({
kind: 'document',
id: 'static/dependencies/subfolder/in-folder.html',
imported: true
}));
chai_1.assert.deepEqual(Array.from(inFolder.getFeatures({ kind: 'document', imported: true }))
.map((d) => d.url), [
'static/dependencies/subfolder/in-folder.html',
'static/dependencies/subfolder/subfolder-sibling.html'
]);
// check de-duplication
chai_1.assert.equal(getOnly(inlineAndImports.getFeatures({
kind: 'document',
id: 'static/dependencies/subfolder/in-folder.html',
imported: true
})), inFolder);
}));
test(`warns for files that don't exist`, () => __awaiter(this, void 0, void 0, function* () {
const url = '/static/does_not_exist';
const result = yield analyzer.analyze([url]);
const warning = result.getDocument(url);
chai_1.assert.isFalse(warning instanceof model_1.Document);
}));
test('handles documents from multiple calls to analyze()', () => __awaiter(this, void 0, void 0, function* () {
yield analyzer.analyze(['static/caching/file1.html']);
yield analyzer.analyze(['static/caching/file2.html']);
}));
test('handles mutually recursive documents', () => __awaiter(this, void 0, void 0, function* () {
const document = yield analyzeDocument('static/circular/mutual-a.html');
const shallowFeatures = document.getFeatures({ imported: false });
chai_1.assert.deepEqual(Array.from(shallowFeatures)
.filter((f) => f.kinds.has('document'))
.map((f) => f.url), ['static/circular/mutual-a.html']);
chai_1.assert.deepEqual(Array.from(shallowFeatures)
.filter((f) => f.kinds.has('import'))
.map((f) => f.url), ['static/circular/mutual-b.html']);
const deepFeatures = document.getFeatures({ imported: true });
chai_1.assert.deepEqual(Array.from(deepFeatures)
.filter((f) => f.kinds.has('document'))
.map((f) => f.url), ['static/circular/mutual-a.html', 'static/circular/mutual-b.html']);
chai_1.assert.deepEqual(Array.from(deepFeatures)
.filter((f) => f.kinds.has('import'))
.map((f) => f.url), ['static/circular/mutual-b.html', 'static/circular/mutual-a.html']);
}));
test('handles parallel analyses of mutually recursive documents', () => __awaiter(this, void 0, void 0, function* () {
// At one point this deadlocked, or threw
// a _makeDocument error.
yield Promise.all([
analyzer.analyze(['static/circular/mutual-a.html']),
analyzer.analyze(['static/circular/mutual-b.html'])
]);
}));
test('handles a document importing itself', () => __awaiter(this, void 0, void 0, function* () {
const document = yield analyzeDocument('static/circular/self-import.html');
const features = document.getFeatures({ imported: true });
chai_1.assert.deepEqual(Array.from(features)
.filter((f) => f.kinds.has('document'))
.map((f) => f.url), ['static/circular/self-import.html']);
chai_1.assert.deepEqual(Array.from(features)
.filter((f) => f.kinds.has('import'))
.map((f) => f.url), [
'static/circular/self-import.html',
'static/circular/self-import.html'
]);
}));
suite('handles documents with spaces in filename', () => {
test('given a url with unencoded spaces to analyze', () => __awaiter(this, void 0, void 0, function* () {
const document = yield analyzeDocument('static/spaces in file.html');
const features = document.getFeatures({ imported: true });
chai_1.assert.deepEqual(Array.from(features)
.filter((f) => f.kinds.has('document'))
.map((f) => f.url), [
'static/spaces%20in%20file.html',
'static/dependencies/spaces%20in%20import.html'
]);
chai_1.assert.deepEqual(Array.from(features)
.filter((f) => f.kinds.has('import'))
.map((f) => f.url), ['static/dependencies/spaces%20in%20import.html']);
}));
test('given a url with encoded spaces to analyze', () => __awaiter(this, void 0, void 0, function* () {
const document = yield analyzeDocument('static/spaces%20in%20file.html');
const features = document.getFeatures({ imported: true });
chai_1.assert.deepEqual(Array.from(features)
.filter((f) => f.kinds.has('document'))
.map((f) => f.url), [
'static/spaces%20in%20file.html',
'static/dependencies/spaces%20in%20import.html'
]);
chai_1.assert.deepEqual(Array.from(features)
.filter((f) => f.kinds.has('import'))
.map((f) => f.url), ['static/dependencies/spaces%20in%20import.html']);
}));
});
});
// TODO: reconsider whether we should test these private methods.
suite('_parse()', () => {
test('loads and parses an HTML document', () => __awaiter(this, void 0, void 0, function* () {
const context = yield getContext(analyzer);
const doc = yield context['_parse']('static/html-parse-target.html');
chai_1.assert.instanceOf(doc, html_document_1.ParsedHtmlDocument);
chai_1.assert.equal(doc.url, 'static/html-parse-target.html');
}));
test('loads and parses a JavaScript document', () => __awaiter(this, void 0, void 0, function* () {
const context = yield getContext(analyzer);
const doc = yield context['_parse']('static/js-elements.js');
chai_1.assert.instanceOf(doc, javascript_document_1.JavaScriptDocument);
chai_1.assert.equal(doc.url, 'static/js-elements.js');
}));
test('returns a Promise that rejects for non-existant files', () => __awaiter(this, void 0, void 0, function* () {
const context = yield getContext(analyzer);
yield chai_1.assert.isRejected(context['_parse']('static/not-found'));
}));
});
suite('_getScannedFeatures()', () => {
test('default import scanners', () => __awaiter(this, void 0, void 0, function* () {
const contents = `<html><head>
<link rel="import" href="polymer.html">
<script src="foo.js"></script>
<link rel="stylesheet" href="foo.css"></link>
</head></html>`;
const document = new html_parser_1.HtmlParser().parse(contents, 'test.html');
const context = yield getContext(analyzer);
const features = (yield context['_getScannedFeatures'](document))
.features;
chai_1.assert.deepEqual(features.map((e) => e.type), ['html-import', 'html-script', 'html-style']);
chai_1.assert.deepEqual(features.map((e) => e.url), //
['polymer.html', 'foo.js', 'foo.css']);
}));
test('polymer css import scanner', () => __awaiter(this, void 0, void 0, function* () {
const contents = `<html><head>
<link rel="import" type="css" href="foo.css">
</head>
<body>
<dom-module>
<link rel="import" type="css" href="bar.css">
</dom-module>
</body></html>`;
const document = new html_parser_1.HtmlParser().parse(contents, 'test.html');
const context = yield getContext(analyzer);
const features = (yield context['_getScannedFeatures'](document))
.features.filter((e) => e instanceof model_1.ScannedImport);
chai_1.assert.equal(features.length, 1);
chai_1.assert.equal(features[0].type, 'css-import');
chai_1.assert.equal(features[0].url, 'bar.css');
}));
test('HTML inline document scanners', () => __awaiter(this, void 0, void 0, function* () {
const contents = `<html><head>
<script>console.log('hi')</script>
<style>body { color: red; }</style>
</head></html>`;
const context = yield getContext(analyzer);
const document = new html_parser_1.HtmlParser().parse(contents, 'test.html');
const features = ((yield context['_getScannedFeatures'](document))
.features);
chai_1.assert.equal(features.length, 2);
chai_1.assert.instanceOf(features[0], model_1.ScannedInlineDocument);
chai_1.assert.instanceOf(features[1], model_1.ScannedInlineDocument);
}));
const testName = 'HTML inline documents can be cloned, modified, and stringified';
test(testName, () => __awaiter(this, void 0, void 0, function* () {
const contents = stripIndent(`
<div>
<script>
console.log('foo');
</script>
<style>
body {
color: blue;
}
</style>
</div>
`).trim();
const modifiedContents = stripIndent(`
<div>
<script>
console.log('bar');
</script>
<style>
body {
color: red;
}
</style>
</div>
`).trim();
inMemoryOverlay.urlContentsMap.set('test-doc.html', contents);
const origDocument = yield analyzeDocument('test-doc.html');
const document = clone(origDocument);
// In document, we'll change `foo` to
// `bar` in the js and `blue` to
// `red` in the css.
const jsDocs = document.getFeatures({ kind: 'js-document', imported: true });
chai_1.assert.equal(1, jsDocs.size);
const jsDoc = getOnly(jsDocs);
jsDoc.parsedDocument.visit([{
enterCallExpression(node) {
node.arguments =
[{ type: 'Literal', value: 'bar', raw: 'bar' }];
}
}]);
const cssDocs = document.getFeatures({ kind: 'css-document', imported: true });
chai_1.assert.equal(1, cssDocs.size);
const cssDoc = getOnly(cssDocs);
cssDoc.parsedDocument.visit([{
visit(node) {
if (node.type === 'expression' && node.text === 'blue') {
node.text = 'red';
}
}
}]);
// We can stringify the clone and get the modified contents, and
// stringify the original and still get the original contents.
chai_1.assert.deepEqual(document.stringify(), modifiedContents);
chai_1.assert.deepEqual(origDocument.stringify(), contents);
}));
});
test('analyzes a document with a namespace', () => __awaiter(this, void 0, void 0, function* () {
const document = yield analyzeDocument('static/namespaces/import-all.html');
if (!(document instanceof model_1.Document)) {
throw new Error(`Expected Document, got ${document}`);
}
const namespaces = Array.from(document.getFeatures({ kind: 'namespace', imported: true }));
chai_1.assert.deepEqual(namespaces.map((b) => b.name), [
'ExplicitlyNamedNamespace',
'ExplicitlyNamedNamespace.NestedNamespace',
'ImplicitlyNamedNamespace',
'ImplicitlyNamedNamespace.NestedNamespace',
'ParentNamespace.FooNamespace',
'ParentNamespace.BarNamespace',
'DynamicNamespace.ComputedProperty',
'DynamicNamespace.UnanalyzableComputedProperty',
'DynamicNamespace.Aliased',
'DynamicNamespace.InferredComputedProperty',
]);
}));
// TODO(rictic): move duplicate checks into scopes/analysis results.
// No where else has reliable knowledge of the clash.
test.skip('creates warnings when duplicate namespaces are analyzed', () => __awaiter(this, void 0, void 0, function* () {
const document = yield analyzer.analyze(['static/namespaces/import-duplicates.html']);
const namespaces = Array.from(document.getFeatures({ kind: 'namespace' }));
chai_1.assert.deepEqual(namespaces.map((b) => b.name), [
'ExplicitlyNamedNamespace',
'ExplicitlyNamedNamespace.NestedNamespace',
]);
const warnings = document.getWarnings();
chai_1.assert.containSubset(warnings, [
{
message: 'Found more than one namespace named ExplicitlyNamedNamespace.',
severity: model_1.Severity.WARNING,
code: 'multiple-javascript-namespaces',
}
]);
chai_1.assert.deepEqual(yield underliner.underline(warnings), [`
var DuplicateNamespace = {};
~~~~~~~~~~~~~~~~~~~~~~~~~~~~`]);
}));
suite('analyzePackage', () => {
test('produces a package with the right documents', () => __awaiter(this, void 0, void 0, function* () {
const analyzer = new analyzer_1.Analyzer({
urlLoader: new fs_url_loader_1.FSUrlLoader(path.join(testDir, 'static', 'project'))
});
const pckage = yield analyzer.analyzePackage();
// The root documents of the package are a minimal set of documents whose
// imports touch every document in the package.
chai_1.assert.deepEqual(Array.from(pckage['_searchRoots']).map((d) => d.url).sort(), ['cyclic-a.html', 'root.html', 'subdir/root-in-subdir.html'].sort());
// Note that this does not contain the bower_components/ files
chai_1.assert.deepEqual(Array.from(pckage.getFeatures({ kind: 'document' }))
.filter((d) => !d.isInline)
.map((d) => d.url)
.sort(), [
'cyclic-a.html',
'cyclic-b.html',
'root.html',
'leaf.html',
'subdir/subdir-leaf.html',
'subdir/root-in-subdir.html'
].sort());
// And this does contain the one imported file in bower_components/
chai_1.assert.deepEqual(Array
.from(pckage.getFeatures({ kind: 'document', externalPackages: true }))
.filter((d) => !d.isInline)
.map((d) => d.url)
.sort(), [
'cyclic-a.html',
'cyclic-b.html',
'root.html',
'leaf.html',
'subdir/subdir-leaf.html',
'subdir/root-in-subdir.html',
'bower_components/imported.html',
].sort());
const packageElements = [
'root-root',
'leaf-leaf',
'cyclic-a',
'cyclic-b',
'root-in-subdir',
'subdir-leaf'
];
// All elements in the package
chai_1.assert.deepEqual(Array.from(pckage.getFeatures({ kind: 'element' }))
.map((e) => e.tagName)
.sort(), packageElements.sort());
// All elements in the package, as well as all elements in
// its bower_components directory that are reachable from imports in the
// package.
chai_1.assert.deepEqual(Array
.from(pckage.getFeatures({ kind: 'element', externalPackages: true }))
.map((e) => e.tagName)
.sort(), packageElements.concat(['imported-dependency']).sort());
}));
test('can get warnings from within and without the package', () => __awaiter(this, void 0, void 0, function* () {
const analyzer = new analyzer_1.Analyzer({
urlLoader: new fs_url_loader_1.FSUrlLoader(path.join(testDir, 'static', 'project-with-errors'))
});
const pckage = yield analyzer.analyzePackage();
chai_1.assert.deepEqual(Array.from(pckage['_searchRoots']).map((d) => d.url), ['index.html']);
chai_1.assert.deepEqual(pckage.getWarnings().map((w) => w.sourceRange.file), ['index.html']);
chai_1.assert.deepEqual(pckage.getWarnings({ externalPackages: true })
.map((w) => w.sourceRange.file)
.sort(), ['bower_components/external-with-warnings.html', 'index.html']);
}));
});
suite('_fork', () => {
test('returns an independent copy of Analyzer', () => __awaiter(this, void 0, void 0, function* () {
inMemoryOverlay.urlContentsMap.set('a.html', 'a is shared');
yield analyzer.analyze(['a.html']);
// Unmap a.html so that future reads of it will fail, thus testing the
// cache.
inMemoryOverlay.urlContentsMap.delete('a.html');
const analyzer2 = yield analyzer._fork();
inMemoryOverlay.urlContentsMap.set('b.html', 'b for analyzer');
yield analyzer.analyze(['b.html']);
inMemoryOverlay.urlContentsMap.set('b.html', 'b for analyzer2');
yield analyzer2.analyze(['b.html']);
inMemoryOverlay.urlContentsMap.delete('b.html');
const a1 = yield analyzeDocument('a.html', analyzer);
const a2 = yield analyzeDocument('a.html', analyzer2);
const b1 = yield analyzeDocument('b.html', analyzer);
const b2 = yield analyzeDocument('b.html', analyzer2);
chai_1.assert.equal(a1.parsedDocument.contents, 'a is shared');
chai_1.assert.equal(a2.parsedDocument.contents, 'a is shared');
chai_1.assert.equal(b1.parsedDocument.contents, 'b for analyzer');
chai_1.assert.equal(b2.parsedDocument.contents, 'b for analyzer2');
}));
test('supports overriding of urlLoader', () => __awaiter(this, void 0, void 0, function* () {
const loader1 = { canLoad: () => true, load: (u) => __awaiter(this, void 0, void 0, function* () { return `${u} 1`; }) };
const loader2 = { canLoad: () => true, load: (u) => __awaiter(this, void 0, void 0, function* () { return `${u} 2`; }) };
const analyzer1 = new analyzer_1.Analyzer({ urlLoader: loader1 });
const a1 = yield analyzeDocument('a.html', analyzer1);
const analyzer2 = yield analyzer1._fork({ urlLoader: loader2 });
const a2 = yield analyzeDocument('a.html', analyzer2);
const b1 = yield analyzeDocument('b.html', analyzer1);
const b2 = yield analyzeDocument('b.html', analyzer2);
chai_1.assert.equal(a1.parsedDocument.contents, 'a.html 1', 'a.html, loader 1');
chai_1.assert.equal(a2.parsedDocument.contents, 'a.html 1', 'a.html, in cache');
chai_1.assert.equal(b1.parsedDocument.contents, 'b.html 1', 'b.html, loader 1');
chai_1.assert.equal(b2.parsedDocument.contents, 'b.html 2', 'b.html, loader 2');
}));
});
suite('race conditions and caching', () => {
test('maintain caches across multiple edits', () => __awaiter(this, void 0, void 0, function* () {
// This is a regression test of a scenario where changing a dependency
// did not properly update warnings of a file. The bug turned out to
// be in the dependency graph, but this test seems useful enough to
// keep around.
// The specific warning is renaming a superclass without updating the
// class which extends it.
inMemoryOverlay.urlContentsMap.set('base.js', `
class BaseElement extends HTMLElement {}
customElements.define('base-elem', BaseElement);
`);
inMemoryOverlay.urlContentsMap.set('user.html', `
<script src="./base.js"></script>
<script>
class UserElem extends BaseElement {}
customElements.define('user-elem', UserElem);
</script>
`);
const b1Doc = yield analyzer.analyze(['base.js']);
chai_1.assert.deepEqual(b1Doc.getWarnings(), []);
const u1Doc = yield analyzer.analyze(['user.html']);
chai_1.assert.deepEqual(u1Doc.getWarnings(), []);
inMemoryOverlay.urlContentsMap.set('base.js', `
class NewSpelling extends HTMLElement {}
customElements.define('base-elem', NewSpelling);
`);
analyzer.filesChanged(['base.js']);
const b2Doc = yield analyzer.analyze(['base.js']);
chai_1.assert.deepEqual(b2Doc.getWarnings(), []);
const u2Doc = yield analyzer.analyze(['user.html']);
chai_1.assert.notEqual(u1Doc, u2Doc);
chai_1.assert.equal(u2Doc.getWarnings()[0].message, 'Unable to resolve superclass BaseElement');
inMemoryOverlay.urlContentsMap.set('base.js', `
class BaseElement extends HTMLElement {}
customElements.define('base-elem', BaseElement);
`);
analyzer.filesChanged(['base.js']);
const b3Doc = yield analyzer.analyze(['base.js']);
chai_1.assert.deepEqual(b3Doc.getWarnings(), []);
const u3Doc = yield analyzer.analyze(['user.html']);
chai_1.assert.equal(u3Doc.getWarnings().length, 0);
}));
class RacyUrlLoader {
constructor(pathToContentsMap, waitFunction) {
this.pathToContentsMap = pathToContentsMap;
this.waitFunction = waitFunction;
}
canLoad() {
return true;
}
load(path) {
return __awaiter(this, void 0, void 0, function* () {
yield this.waitFunction();
const contents = this.pathToContentsMap.get(path);
if (contents != null) {
return contents;
}
throw new Error(`no known contents for ${path}`);
});
}
}
const editorSimulator = (waitFn) => __awaiter(this, void 0, void 0, function* () {
// Here we're simulating a lot of noop-changes to base.html,
// which has
// two imports, which mutually import a common dep. This
// stresses the
// analyzer's caching.
const contentsMap = new Map([
[
'base.html',
`<link rel="import" href="a.html">\n<link rel="import" href="b.html">`
],
['a.html', `<link rel="import" href="common.html">`],
['b.html', `<link rel="import" href="common.html">`],
['common.html', `<custom-el></custom-el>`],
]);
const analyzer = new analyzer_1.Analyzer({ urlLoader: new RacyUrlLoader(contentsMap, waitFn) });
const promises = [];
const intermediatePromises = [];
for (let i = 0; i < 1; i++) {
yield waitFn();
for (const entry of contentsMap) {
// Randomly edit some files.
const path = entry[0];
if (Math.random() > 0.5) {
analyzer.filesChanged([path]);
analyzer.analyze([path]);
if (Math.random() > 0.5) {
analyzer.filesChanged([path]);
const p = analyzer.analyze([path]);
const cacheContext = yield getContext(analyzer);
intermediatePromises.push((() => __awaiter(this, void 0, void 0, function* () {
yield p;
const docs = Array.from(cacheContext['_cache'].analyzedDocuments.values());
chai_1.assert.isTrue(new Set(docs.map((d) => d.url).sort()).has(path));
}))());
}
}
promises.push(analyzeDocument('base.html', analyzer));
yield Promise.all(promises);
}
// Analyze the base file
promises.push(analyzeDocument('base.html', analyzer));
yield Promise.all(promises);
}
// Assert that all edits went through fine.
yield Promise.all(intermediatePromises);
// Assert that the every analysis of 'base.html' after each
// batch of edits
// was correct, and doesn't have missing or inconsistent
// results.
const documents = yield Promise.all(promises);
for (const document of documents) {
chai_1.assert.deepEqual(document.url, 'base.html');
const localFeatures = document.getFeatures({ imported: false });
const kinds = Array.from(localFeatures).map((f) => Array.from(f.kinds));
const message = `localFeatures: ${JSON.stringify(Array.from(localFeatures)
.map((f) => ({
kinds: Array.from(f.kinds),
ids: Array.from(f.identifiers)
})))}`;
chai_1.assert.deepEqual(kinds, [
['document', 'html-document'],
['import', 'html-import'],
['import', 'html-import']
], message);
const imports = Array.from(document.getFeatures({ kind: 'import', imported: true }));
chai_1.assert.sameMembers(imports.map((m) => m.url), ['a.html', 'b.html', 'common.html', 'common.html']);
const docs = Array.from(document.getFeatures({ kind: 'document', imported: true }));
chai_1.assert.sameMembers(docs.map((d) => d.url), ['a.html', 'b.html', 'base.html', 'common.html']);
const refs = Array.from(document.getFeatures({ kind: 'element-reference', imported: true }));
chai_1.assert.sameMembers(refs.map((ref) => ref.tagName), ['custom-el']);
}
});
test('editor simulator of imports that import a common dep', () => __awaiter(this, void 0, void 0, function* () {
const waitTimes = [];
const randomWait = () => new Promise((resolve) => {
const waitTime = Math.random() * 30;
waitTimes.push(waitTime);
setTimeout(resolve, waitTime);
});
try {
yield editorSimulator(randomWait);
}
catch (err) {
console.error('Wait times to reproduce this failure:');
console.error(JSON.stringify(waitTimes));
throw err;
}
}));
/**
* This is a tool for reproducing and debugging a failure of the
* editor
* simulator test above, but only at the exact same commit, as
* it's
* sensitive to the order of internal operations of the analyzer.
* So this
* code with a defined list of wait times should not be checked
* in.
*
* It's also worth noting that this code will be dependent on many
* other
* system factors, so it's only somewhat more reproducible, and
* may not
* end
* up being very useful. If it isn't, we should delete it.
*/
test.skip('somewhat more reproducable editor simulator', () => __awaiter(this, void 0, void 0, function* () {
// Replace waitTimes' value with the array of wait times
// that's logged
// to the console when the random editor test fails.
const waitTimes = [];
const reproducableWait = () => new Promise((resolve) => {
const waitTime = waitTimes.shift();
if (waitTime == null) {
throw new Error('Was asked for more random waits than the ' +
'given array of wait times');
}
setTimeout(resolve, waitTime);
});
yield editorSimulator(reproducableWait);
}));
suite('deterministic tests', () => {
// Deterministic tests extracted from various failures of the
// above
// random
// test.
/**
* This is an asynchronous keyed queue, useful for controlling
* the
* order
* of results in order to make tests more deterministic.
*
* It's intended to be used in fake loaders, scanners, etc,
* where the
* test
* provides the intended result on a file by file basis, with
* control
* over
* the order in which the results come in.
*/
class KeyedQueue {
constructor() {
this._requests = new Map();
this._results = new Map();
}
request(key) {
return __awaiter(this, void 0, void 0, function* () {
const results = this._results.get(key) || [];
if (results.length > 0) {
return results.shift();
}
const deferred = new utils_1.Deferred();
const deferreds = this._requests.get(key) || [];
this._requests.set(key, deferreds);
deferreds.push(deferred);
return deferred.promise;
});
}
/**
* Resolves the next unfulfilled request for the given key
* with the
* given value.
*/
resolve(key, value) {
const requests = this._requests.get(key) || [];
if (requests.length > 0) {
const request = requests.shift();
request.resolve(value);
return;
}
const results = this._results.get(key) || [];
this._results.set(key, results);
results.push(value);
}
toString() {
return JSON.stringify({
openRequests: Array.from(this._requests.keys()),
openResponses: Array.from(this._results.keys())
});
}
}
class DeterministicUrlLoader {
constructor() {
this.queue = new KeyedQueue();
}
canLoad(_url) {
return true;
}
load(url) {
return __awaiter(this, void 0, void 0, function* () {
return this.queue.request(url);
});
}
}
class NoopUrlLoader {
canLoad() {
return true;
}
load() {
return __awaiter(this, void 0, void 0, function* () {
throw new Error(`Noop Url Loader isn't supposed to be actually called.`);
});
}
}
/**
* This crashed the analyzer as there was a race to
* _makeDocument,
* violating its constraint that there not already be a resolved
* Document
* for a given path.
*
* This test came out of debugging this issue:
* https://github.com/Polymer/polymer-analyzer/issues/406
*/
test('two edits of the same file back to back', () => __awaiter(this, void 0, void 0, function* () {
const overlay = new overlay_loader_1.InMemoryOverlayUrlLoader(new NoopUrlLoader);
const analyzer = new analyzer_1.Analyzer({ urlLoader: overlay });
overlay.urlContentsMap.set('leaf.html', 'Hello');
const p1 = analyzer.analyze(['leaf.html']);
overlay.urlContentsMap.set('leaf.html', 'World');
analyzer.filesChanged(['leaf.html']);
const p2 = analyzer.analyze(['leaf.html']);