From: GoalSmashers Date: Tue, 10 Dec 2013 12:14:54 +0000 (+0100) Subject: Fixes #85 - adds resolving protocol @import statements. X-Git-Url: https://git.ndcode.org/public/gitweb.cgi?a=commitdiff_plain;h=2b2b586251efa8f355fd575d1bb92f4470cd687b;p=clean-css.git Fixes #85 - adds resolving protocol @import statements. * Rewrote inliner to process data asynchronously. * Supports 2xx responses, redirects, errors, and timeouts. * Supports cyclical references. * Supports protocol-less requests (defaults to HTTP). * Supports overriding request options - see http://nodejs.org/api/http.html#http_http_request_options_callback * Supports timeout in ./bin/cleancss via --timeout / -t switches. * Supports inlining local resources only without a callback. * Supports rebasing URLs in remote @imports. * Always triggers a callback asynchronously. --- diff --git a/History.md b/History.md index 82d5d924..6b803342 100644 --- a/History.md +++ b/History.md @@ -4,6 +4,7 @@ * Adds an optional callback to minify method. * Deprecates --selectors-merge-mode / selectorsMergeMode in favor to --compatibility / compatibility. * Skips empty removal if advanced processing is enabled. +* Fixed issue [#85](https://github.com/GoalSmashers/clean-css/issues/85) - resolving protocol `@import`s. * Fixed issue [#160](https://github.com/GoalSmashers/clean-css/issues/160) - re-runs optimizer until a clean pass. * Fixed issue [#161](https://github.com/GoalSmashers/clean-css/issues/161) - improves tokenizer performance. * Fixed issue [#163](https://github.com/GoalSmashers/clean-css/issues/163) - round pixels to 2nd decimal place. diff --git a/bin/cleancss b/bin/cleancss index 6fe291c3..c5f80dbc 100755 --- a/bin/cleancss +++ b/bin/cleancss @@ -28,6 +28,7 @@ commands .option('--skip-advanced', 'Disable advanced optimizations - selector & property merging, reduction, etc.') .option('--selectors-merge-mode [ie8|*]', 'DEPRECATED: Use --compatibility switch') .option('-c, --compatibility [ie8]', 'Force compatibility mode') + .option('-t, --timeout [seconds]', 'Per connection timeout when fetching remote @imports (defaults to 5 seconds)') .option('-d, --debug', 'Shows debug information (minification time & compression efficiency)'); commands.on('--help', function() { @@ -84,6 +85,8 @@ if (commands.selectorsMergeMode) { } if (commands.debug) cleanOptions.debug = true; +if (commands.timeout) + cleanOptions.inliner = { timeout: parseFloat(commands.timeout) * 1000 }; if (commands.args.length > 0) { var source = commands.args[0]; options.source = source; @@ -95,7 +98,7 @@ if (options.source) { fs.readFile(options.source, 'utf8', function(error, data) { if (error) throw error; - output(minify(data)); + minify(data); }); } else { var stdin = process.openStdin(); @@ -105,28 +108,27 @@ if (options.source) { data += chunk; }); stdin.on('end', function() { - output(minify(data)); + minify(data); }); } function minify(data) { - var minifier = new CleanCSS(cleanOptions); - var minified = minifier.minify(data); - - if (cleanOptions.debug) { - console.error('Original: %d bytes', minifier.stats.originalSize); - console.error('Minified: %d bytes', minifier.stats.minifiedSize); - console.error('Efficiency: %d%', ~~(minifier.stats.efficiency * 10000) / 100.0); - console.error('Time spent: %dms', minifier.stats.timeSpent); - } + new CleanCSS(cleanOptions).minify(data, function(errors, minified) { + if (cleanOptions.debug) { + console.error('Original: %d bytes', this.stats.originalSize); + console.error('Minified: %d bytes', this.stats.minifiedSize); + console.error('Efficiency: %d%', ~~(this.stats.efficiency * 10000) / 100.0); + console.error('Time spent: %dms', this.stats.timeSpent); + } - outputFeedback(minifier.errors, true); - outputFeedback(minifier.warnings); + outputFeedback(this.errors, true); + outputFeedback(this.warnings); - if (minifier.errors.length > 0) - process.exit(1); + if (this.errors.length > 0) + process.exit(1); - return minified; + output(minified); + }); } function output(minified) { diff --git a/lib/clean.js b/lib/clean.js index 2c3b03f2..3d8ddee0 100644 --- a/lib/clean.js +++ b/lib/clean.js @@ -5,6 +5,8 @@ * Copyright (C) 2011-2014 GoalSmashers.com */ +/* jshint latedef: false */ + var ColorShortener = require('./colors/shortener'); var ColorHSLToHex = require('./colors/hsl-to-hex'); var ColorRGBToHex = require('./colors/rgb-to-hex'); @@ -52,19 +54,52 @@ var CleanCSS = module.exports = function CleanCSS(options) { }; CleanCSS.prototype.minify = function(data, callback) { - var startedAt; - var stats = this.stats; var options = this.options; - var context = this.context; - var lineBreak = this.lineBreak; if (Buffer.isBuffer(data)) data = data.toString(); if (options.debug) { - startedAt = process.hrtime(); - stats.originalSize = data.length; + this.startedAt = process.hrtime(); + this.stats.originalSize = data.length; + } + + if (options.processImport) { + // inline all imports + var self = this; + var runner = callback ? + process.nextTick : + function(callback) { return callback(); }; + + return runner(function() { + return new ImportInliner(self.context, options.inliner).process(data, { + localOnly: !callback, + root: options.root || process.cwd(), + relativeTo: options.relativeTo, + whenDone: function(data) { + return minify.call(self, data, callback); + } + }); + }); + } else { + return minify.call(this, data, callback); } +}; + +var minify = function(data, callback) { + var stats = this.stats; + var options = this.options; + var context = this.context; + var lineBreak = this.lineBreak; + + var commentsProcessor = new CommentsProcessor( + 'keepSpecialComments' in options ? options.keepSpecialComments : '*', + options.keepBreaks, + lineBreak + ); + var expressionsProcessor = new ExpressionsProcessor(); + var freeTextProcessor = new FreeTextProcessor(); + var urlsProcessor = new UrlsProcessor(); var replace = function() { if (typeof arguments[0] == 'function') @@ -89,26 +124,6 @@ CleanCSS.prototype.minify = function(data, callback) { }; } - var commentsProcessor = new CommentsProcessor( - 'keepSpecialComments' in options ? options.keepSpecialComments : '*', - options.keepBreaks, - lineBreak - ); - var expressionsProcessor = new ExpressionsProcessor(); - var freeTextProcessor = new FreeTextProcessor(); - var urlsProcessor = new UrlsProcessor(); - var importInliner = new ImportInliner(context); - - if (options.processImport) { - // inline all imports - replace(function inlineImports() { - data = importInliner.process(data, { - root: options.root || process.cwd(), - relativeTo: options.relativeTo - }); - }); - } - replace(function escapeComments() { data = commentsProcessor.escape(data); }); @@ -342,7 +357,7 @@ CleanCSS.prototype.minify = function(data, callback) { data = data.trim(); if (options.debug) { - var elapsed = process.hrtime(startedAt); + var elapsed = process.hrtime(this.startedAt); stats.timeSpent = ~~(elapsed[0] * 1e3 + elapsed[1] / 1e6); stats.efficiency = 1 - data.length / stats.originalSize; stats.minifiedSize = data.length; diff --git a/lib/images/url-rewriter.js b/lib/images/url-rewriter.js index 14df441e..af17d5ba 100644 --- a/lib/images/url-rewriter.js +++ b/lib/images/url-rewriter.js @@ -1,4 +1,5 @@ var path = require('path'); +var url = require('url'); module.exports = { process: function(data, options) { @@ -27,23 +28,26 @@ module.exports = { data; }, - _rebased: function(url, options) { - var specialUrl = url[0] == '/' || - url.substring(url.length - 4) == '.css' || - url.indexOf('data:') === 0 || - /^https?:\/\//.exec(url) !== null || - /__\w+__/.exec(url) !== null; + _rebased: function(resource, options) { + var specialUrl = resource[0] == '/' || + resource.substring(resource.length - 4) == '.css' || + resource.indexOf('data:') === 0 || + /^https?:\/\//.exec(resource) !== null || + /__\w+__/.exec(resource) !== null; var rebased; if (specialUrl) - return url; + return resource; + + if (/https?:\/\//.test(options.toBase)) + return url.resolve(options.toBase, resource); if (options.absolute) { rebased = path - .resolve(path.join(options.fromBase, url)) + .resolve(path.join(options.fromBase, resource)) .replace(options.toBase, ''); } else { - rebased = path.relative(options.toBase, path.join(options.fromBase, url)); + rebased = path.relative(options.toBase, path.join(options.fromBase, resource)); } return process.platform == 'win32' ? diff --git a/lib/imports/inliner.js b/lib/imports/inliner.js index 95bd13f6..8e26ca85 100644 --- a/lib/imports/inliner.js +++ b/lib/imports/inliner.js @@ -1,11 +1,35 @@ var fs = require('fs'); var path = require('path'); +var http = require('http'); +var https = require('https'); +var url = require('url'); var UrlRewriter = require('../images/url-rewriter'); -module.exports = function Inliner(context) { +var merge = function(source1, source2) { + var target = {}; + for (var key1 in source1) + target[key1] = source1[key1]; + for (var key2 in source2) + target[key2] = source2[key2]; + + return target; +}; + +module.exports = function Inliner(context, options) { + var defaultOptions = { + timeout: 5000, + request: {} + }; + var inlinerOptions = merge(defaultOptions, options || {}); + var process = function(data, options) { - var tempData = []; + options._shared = options._shared || { + done: [], + left: [] + }; + var shared = options._shared; + var nextStart = 0; var nextEnd = 0; var cursor = 0; @@ -27,19 +51,27 @@ module.exports = function Inliner(context) { nextEnd = data.indexOf(';', nextStart); if (nextEnd == -1) { - tempData.push(''); cursor = data.length; + data = ''; break; } - tempData.push(data.substring(cursor, nextStart)); - tempData.push(inlinedFile(data, nextStart, nextEnd, options)); - cursor = nextEnd + 1; + shared.done.push(data.substring(cursor, nextStart)); + shared.left.unshift([data.substring(nextEnd + 1), options]); + + return inline(data, nextStart, nextEnd, options); } - return tempData.length > 0 ? - tempData.join('') + data.substring(cursor, data.length) : - data; + // no @import matched in current data + shared.done.push(data); + return processNext(options); + }; + + var processNext = function(options) { + if (options._shared.left.length > 0) + return process.apply(null, options._shared.left.shift()); + else + return options.whenDone(options._shared.done.join('')); }; var commentScanner = function(data) { @@ -93,7 +125,7 @@ module.exports = function Inliner(context) { return scanner; }; - var inlinedFile = function(data, nextStart, nextEnd, options) { + var inline = function(data, nextStart, nextEnd, options) { var strippedImport = data .substring(data.indexOf(' ', nextStart) + 1, nextEnd) .replace(/^url\(/, '') @@ -107,9 +139,92 @@ module.exports = function Inliner(context) { .substring(importedFile.length + 1) .trim(); - if (/^(http|https):\/\//.test(importedFile) || /^\/\//.test(importedFile)) - return '@import url(' + importedFile + ')' + (mediaQuery.length > 0 ? ' ' + mediaQuery : '') + ';'; + var isRemote = options.isRemote || + /^(http|https):\/\//.test(importedFile) || + /^\/\//.test(importedFile); + + if (options.localOnly && isRemote) { + context.warnings.push('Ignoring remote @import declaration of "' + importedFile + '" as no callback given.'); + restoreImport(importedFile, mediaQuery, options); + + return processNext(options); + } + + var method = isRemote ? inlineRemoteResource : inlineLocalResource; + return method(importedFile, mediaQuery, options); + }; + + var inlineRemoteResource = function(importedFile, mediaQuery, options) { + var importedUrl = /^https?:\/\//.test(importedFile) ? + importedFile : + url.resolve(options.relativeTo, importedFile); + + if (importedUrl.indexOf('//') === 0) + importedUrl = 'http:' + importedUrl; + + if (options.visited.indexOf(importedUrl) > -1) + return processNext(options); + + options.visited.push(importedUrl); + + var get = importedUrl.indexOf('http://') === 0 ? + http.get : + https.get; + + var timedOut = false; + var handleError = function(message) { + context.errors.push('Broken @import declaration of "' + importedUrl + '" - ' + message); + restoreImport(importedUrl, mediaQuery, options); + + processNext(options); + }; + var requestOptions = merge(url.parse(importedUrl), inlinerOptions.request); + + get(requestOptions, function(res) { + if (res.statusCode < 200 || res.statusCode > 399) { + return handleError('error ' + res.statusCode); + } else if (res.statusCode > 299) { + var movedUrl = url.resolve(importedUrl, res.headers.location); + return inlineRemoteResource(movedUrl, mediaQuery, options); + } + + var chunks = []; + var parsedUrl = url.parse(importedUrl); + res.on('data', function(chunk) { + chunks.push(chunk.toString()); + }); + res.on('end', function() { + var importedData = chunks.join(''); + importedData = UrlRewriter.process(importedData, { toBase: importedUrl }); + + if (mediaQuery.length > 0) + importedData = '@media ' + mediaQuery + '{' + importedData + '}'; + + process(importedData, { + isRemote: true, + relativeTo: parsedUrl.protocol + '//' + parsedUrl.host, + _shared: options._shared, + whenDone: options.whenDone, + visited: options.visited + }); + }); + }) + .on('error', function(res) { + handleError(res.message); + }) + .on('timeout', function() { + // FIX: node 0.8 fires this event twice + if (timedOut) + return; + + handleError('timeout'); + timedOut = true; + }) + .setTimeout(inlinerOptions.timeout); + }; + + var inlineLocalResource = function(importedFile, mediaQuery, options) { var relativeTo = importedFile[0] == '/' ? options.root : options.relativeTo; @@ -118,11 +233,12 @@ module.exports = function Inliner(context) { if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) { context.errors.push('Broken @import declaration of "' + importedFile + '"'); - return ''; + return processNext(options); } - if (options.visited.indexOf(fullPath) != -1) - return ''; + if (options.visited.indexOf(fullPath) > -1) + return processNext(options); + options.visited.push(fullPath); @@ -134,15 +250,23 @@ module.exports = function Inliner(context) { toBase: options._baseRelativeTo }); - var inlinedData = process(importedData, { + if (mediaQuery.length > 0) + importedData = '@media ' + mediaQuery + '{' + importedData + '}'; + + return process(importedData, { root: options.root, relativeTo: importRelativeTo, _baseRelativeTo: options.baseRelativeTo, - visited: options.visited + _shared: options._shared, + visited: options.visited, + whenDone: options.whenDone, + localOnly: options.localOnly }); - return mediaQuery.length > 0 ? - '@media ' + mediaQuery + '{' + inlinedData + '}' : - inlinedData; + }; + + var restoreImport = function(importedUrl, mediaQuery, options) { + var restoredImport = '@import url(' + importedUrl + ')' + (mediaQuery.length > 0 ? ' ' + mediaQuery : '') + ';'; + options._shared.done.push(restoredImport); }; // Inlines all imports taking care of repetitions, unknown files, and circular dependencies diff --git a/package.json b/package.json index d8d5e61a..1edc555e 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ }, "devDependencies": { "jshint": "2.4.x", + "nock": "0.25.x", "vows": "0.7.x" }, "jshintConfig": { diff --git a/test/batch-test.js b/test/batch-test.js index b5194e88..42363df9 100644 --- a/test/batch-test.js +++ b/test/batch-test.js @@ -21,22 +21,22 @@ var batchContexts = function() { return { plain: fs.readFileSync(plainPath, 'utf-8'), - minimized: fs.readFileSync(minPath, 'utf-8'), + preminified: fs.readFileSync(minPath, 'utf-8'), root: path.dirname(plainPath) }; } }; context[testName]['minimizing ' + testName + '.css'] = function(data) { - var processed = new CleanCSS({ + new CleanCSS({ keepBreaks: true, root: data.root - }).minify(data.plain); + }).minify(data.plain, function(errors, minified) { + var minifiedTokens = minified.split(lineBreak); + var preminifiedTokens = data.preminified.split(lineBreak); - var processedTokens = processed.split(lineBreak); - var minimizedTokens = data.minimized.split(lineBreak); - - processedTokens.forEach(function(line, i) { - assert.equal(line, minimizedTokens[i]); + minifiedTokens.forEach(function(line, i) { + assert.equal(line, preminifiedTokens[i]); + }); }); }; }); diff --git a/test/bench.js b/test/bench.js index c821605e..09fb0dbe 100644 --- a/test/bench.js +++ b/test/bench.js @@ -5,7 +5,7 @@ var benchDir = path.join(__dirname, 'data-bench'); var cssData = require('fs').readFileSync(path.join(benchDir, 'complex.css'), 'utf8'); var start = process.hrtime(); -new CleanCSS({ benchmark: true, root: benchDir }).minify(cssData); - -var itTook = process.hrtime(start); -console.log('complete minification: %d ms', 1000 * itTook[0] + itTook[1] / 1000000); +new CleanCSS({ benchmark: true, root: benchDir }).minify(cssData, function() { + var itTook = process.hrtime(start); + console.log('complete minification: %d ms', 1000 * itTook[0] + itTook[1] / 1000000); +}); diff --git a/test/binary-test.js b/test/binary-test.js index d4b4a7ab..bbf9617a 100644 --- a/test/binary-test.js +++ b/test/binary-test.js @@ -2,6 +2,7 @@ var vows = require('vows'); var assert = require('assert'); var exec = require('child_process').exec; var fs = require('fs'); +var http = require('http'); var isWindows = process.platform == 'win32'; var lineBreak = isWindows ? /\r\n/g : /\n/g; @@ -27,6 +28,10 @@ var pipedContext = function(css, options, context) { return context; }; +var unixOnlyContext = function(context) { + return isWindows ? {} : context; +}; + var readFile = function(filename) { return fs.readFileSync(filename, 'utf-8').replace(lineBreak, ''); }; @@ -216,5 +221,27 @@ exports.commandsSuite = vows.describe('binary commands').addBatch({ assert.include(stdout, 'url(../components/jquery-ui/images/next.gif)'); } }) - } + }, + 'timeout': unixOnlyContext({ + topic: function() { + var self = this; + var source = '@import url(http://localhost:24682/timeout.css);'; + + this.server = http.createServer(function() { + setTimeout(function() {}, 1000); + }); + this.server.listen('24682', function() { + exec('echo "' + source + '" | ./bin/cleancss --timeout 0.01', self.callback); + }); + }, + 'should raise warning': function(error, stdout, stderr) { + assert.include(stderr, 'Broken @import declaration of "http://localhost:24682/timeout.css" - timeout'); + }, + 'should output empty response': function(error, stdout) { + assert.equal(stdout, ''); + }, + teardown: function() { + this.server.close(); + } + }) }); diff --git a/test/module-test.js b/test/module-test.js index 8cce9fa3..72904374 100644 --- a/test/module-test.js +++ b/test/module-test.js @@ -167,10 +167,11 @@ vows.describe('module tests').addBatch({ }, 'if both root and output used reasons given': function(minifier) { assert.doesNotThrow(function() { - minifier.minify('@import url(/some/fake/file);'); + minifier.minify('@import url(/some/fake/file);', function(errors) { + assert.equal(errors.length, 1); + assert.equal(errors[0], 'Broken @import declaration of "/some/fake/file"'); + }); }); - assert.equal(minifier.errors.length, 1); - assert.equal(minifier.errors[0], 'Broken @import declaration of "/some/fake/file"'); } }, 'buffer passed in': { diff --git a/test/protocol-imports-test.js b/test/protocol-imports-test.js new file mode 100644 index 00000000..f0d1a3bc --- /dev/null +++ b/test/protocol-imports-test.js @@ -0,0 +1,390 @@ +/* jshint unused: false */ + +var vows = require('vows'); +var assert = require('assert'); +var http = require('http'); +var nock = require('nock'); +var CleanCSS = require('../index'); + +var port = 24682; + +if (process.platform == 'win32') + return; + +vows.describe('protocol imports').addBatch({ + 'of a missing file': { + topic: function() { + this.reqMocks = nock('http://goalsmashers.com') + .get('/missing.css') + .reply(404); + + new CleanCSS().minify('@import url(http://goalsmashers.com/missing.css);a{color:red}', this.callback); + }, + 'should raise error': function(errors, minified) { + assert.equal(errors.length, 1); + }, + 'should ignore @import': function(errors, minified) { + assert.equal(minified, '@import url(http://goalsmashers.com/missing.css);a{color:red}'); + }, + teardown: function() { + assert.equal(this.reqMocks.isDone(), true); + nock.restore(); + } + }, + 'of an existing file': { + topic: function() { + this.reqMocks = nock('http://goalsmashers.com') + .get('/present.css') + .reply(200, 'p{font-size:13px}'); + + new CleanCSS().minify('@import url(http://goalsmashers.com/present.css);a{color:red}', this.callback); + }, + 'should not raise errors': function(errors, minified) { + assert.isNull(errors); + }, + 'should process @import': function(errors, minified) { + assert.equal(minified, 'p{font-size:13px}a{color:red}'); + }, + teardown: function() { + assert.equal(this.reqMocks.isDone(), true); + nock.restore(); + } + }, + 'of an existing file via HTTPS': { + topic: function() { + this.reqMocks = nock('https://goalsmashers.com') + .get('/present.css') + .reply(200, 'p{font-size:13px}'); + + new CleanCSS().minify('@import url(https://goalsmashers.com/present.css);a{color:red}', this.callback); + }, + 'should not raise errors': function(errors, minified) { + assert.isNull(errors); + }, + 'should process @import': function(errors, minified) { + assert.equal(minified, 'p{font-size:13px}a{color:red}'); + }, + teardown: function() { + assert.equal(this.reqMocks.isDone(), true); + nock.restore(); + } + }, + 'of an existing file with media': { + topic: function() { + this.reqMocks = nock('http://goalsmashers.com') + .get('/present.css') + .reply(200, 'p{font-size:13px}'); + + new CleanCSS().minify('@import url(http://goalsmashers.com/present.css) screen;a{color:red}', this.callback); + }, + 'should not raise errors': function(errors, minified) { + assert.isNull(errors); + }, + 'should process @import': function(errors, minified) { + assert.equal(minified, '@media screen{p{font-size:13px}}a{color:red}'); + }, + teardown: function() { + assert.equal(this.reqMocks.isDone(), true); + nock.restore(); + } + }, + 'of an existing file with dependencies': { + topic: function() { + this.reqMocks1 = nock('http://goalsmashers.com') + .get('/present.css') + .reply(200, '@import url(/vendor/reset.css);@import url(https://assets.goalsmashers.com/base.css);p{font-size:13px}') + .get('/vendor/reset.css') + .reply(200, 'body{margin:0}'); + this.reqMocks2 = nock('https://assets.goalsmashers.com') + .get('/base.css') + .reply(200, 'div{padding:0}'); + + new CleanCSS().minify('@import url(http://goalsmashers.com/present.css);a{color:red}', this.callback); + }, + 'should not raise errors': function(errors, minified) { + assert.isNull(errors); + }, + 'should process @import': function(errors, minified) { + assert.equal(minified, 'body{margin:0}div{padding:0}p{font-size:13px}a{color:red}'); + }, + teardown: function() { + assert.equal(this.reqMocks1.isDone(), true); + assert.equal(this.reqMocks2.isDone(), true); + nock.restore(); + } + }, + 'of an existing file with relative dependencies': { + topic: function() { + this.reqMocks = nock('http://goalsmashers.com') + .get('/nested/present.css') + .reply(200, '@import url(../vendor/reset.css);p{font-size:13px}') + .get('/vendor/reset.css') + .reply(200, 'body{margin:0}'); + + new CleanCSS().minify('@import url(http://goalsmashers.com/nested/present.css);a{color:red}', this.callback); + }, + 'should not raise errors': function(errors, minified) { + assert.isNull(errors); + }, + 'should process @import': function(errors, minified) { + assert.equal(minified, 'body{margin:0}p{font-size:13px}a{color:red}'); + }, + teardown: function() { + assert.equal(this.reqMocks.isDone(), true); + nock.restore(); + } + }, + 'of an existing file missing relative dependency': { + topic: function() { + this.reqMocks = nock('http://goalsmashers.com') + .get('/nested/present.css') + .reply(200, '@import url(../missing.css);p{font-size:13px}') + .get('/missing.css') + .reply(404); + + new CleanCSS().minify('@import url(http://goalsmashers.com/nested/present.css);a{color:red}', this.callback); + }, + 'should not raise errors': function(errors, minified) { + assert.equal(errors.length, 1); + assert.equal(errors[0], 'Broken @import declaration of "http://goalsmashers.com/missing.css" - error 404'); + }, + 'should process @import': function(errors, minified) { + assert.equal(minified, '@import url(http://goalsmashers.com/missing.css);p{font-size:13px}a{color:red}'); + }, + teardown: function() { + assert.equal(this.reqMocks.isDone(), true); + nock.restore(); + } + }, + 'of an existing file with URLs to rebase': { + topic: function() { + this.reqMocks = nock('http://goalsmashers.com') + .get('/urls.css') + .reply(200, 'a{background:url(test.png)}'); + + new CleanCSS().minify('@import url(http://goalsmashers.com/urls.css);', this.callback); + }, + 'should not raise errors': function(errors, minified) { + assert.isNull(errors); + }, + 'should process @import': function(errors, minified) { + assert.equal(minified, 'a{background:url(http://goalsmashers.com/test.png)}'); + }, + teardown: function() { + assert.equal(this.reqMocks.isDone(), true); + nock.restore(); + } + }, + 'of an existing file with relative URLs to rebase': { + topic: function() { + this.reqMocks = nock('http://goalsmashers.com') + .get('/base.css') + .reply(200, '@import url(deeply/nested/urls.css);') + .get('/deeply/nested/urls.css') + .reply(200, 'a{background:url(../images/test.png)}'); + + new CleanCSS().minify('@import url(http://goalsmashers.com/base.css);', this.callback); + }, + 'should not raise errors': function(errors, minified) { + assert.isNull(errors); + }, + 'should process @import': function(errors, minified) { + assert.equal(minified, 'a{background:url(http://goalsmashers.com/deeply/images/test.png)}'); + }, + teardown: function() { + assert.equal(this.reqMocks.isDone(), true); + nock.restore(); + } + }, + 'of a non-resolvable domain': { + topic: function() { + new CleanCSS().minify('@import url(http://notdefined.goalsmashers.com/custom.css);a{color:red}', this.callback); + }, + 'should not raise errors': function(errors, minified) { + assert.equal(errors.length, 1); + assert.equal(errors[0], 'Broken @import declaration of "http://notdefined.goalsmashers.com/custom.css" - getaddrinfo ENOTFOUND'); + }, + 'should process @import': function(errors, minified) { + assert.equal(minified, '@import url(http://notdefined.goalsmashers.com/custom.css);a{color:red}'); + } + }, + 'of a 30x response with absolute URL': { + topic: function() { + this.reqMocks = nock('http://goalsmashers.com') + .get('/moved.css') + .reply(301, '', { 'Location': 'http://goalsmashers.com/present.css' }) + .get('/present.css') + .reply(200, 'body{margin:0}'); + + new CleanCSS().minify('@import url(http://goalsmashers.com/moved.css);a{color:red}', this.callback); + }, + 'should not raise errors': function(errors, minified) { + assert.isNull(errors); + }, + 'should process @import': function(errors, minified) { + assert.equal(minified, 'body{margin:0}a{color:red}'); + }, + teardown: function() { + assert.equal(this.reqMocks.isDone(), true); + nock.restore(); + } + }, + 'of a 30x response with relative URL': { + topic: function() { + this.reqMocks = nock('http://goalsmashers.com') + .get('/moved.css') + .reply(301, '', { 'Location': '/present.css' }) + .get('/present.css') + .reply(200, 'body{margin:0}'); + + new CleanCSS().minify('@import url(http://goalsmashers.com/moved.css);a{color:red}', this.callback); + }, + 'should not raise errors': function(errors, minified) { + assert.isNull(errors); + }, + 'should process @import': function(errors, minified) { + assert.equal(minified, 'body{margin:0}a{color:red}'); + }, + teardown: function() { + assert.equal(this.reqMocks.isDone(), true); + nock.restore(); + } + }, + 'of a timed out response': { + topic: function() { + var self = this; + var timeout = 100; + this.server = http.createServer(function(req, res) { + setTimeout(function() {}, timeout * 2); + }); + this.server.listen(port, function() { + new CleanCSS({ + inliner: { + timeout: timeout + } + }).minify('@import url(http://localhost:' + port + '/timeout.css);a{color:red}', self.callback); + }); + }, + 'should not raise errors': function(errors, minified) { + assert.equal(errors.length, 1); + assert.equal(errors[0], 'Broken @import declaration of "http://localhost:' + port + '/timeout.css" - timeout'); + }, + 'should process @import': function(errors, minified) { + assert.equal(minified, '@import url(http://localhost:' + port + '/timeout.css);a{color:red}'); + }, + teardown: function() { + this.server.close(); + } + }, + 'of a cyclical reference response': { + topic: function() { + this.reqMocks = nock('http://goalsmashers.com') + .get('/one.css') + .reply(200, '@import url(/two.css);div{padding:0}') + .get('/two.css') + .reply(200, '@import url(http://goalsmashers.com/two.css);body{margin:0}'); + + new CleanCSS().minify('@import url(http://goalsmashers.com/one.css);a{color:red}', this.callback); + }, + 'should not raise errors': function(errors, minified) { + assert.isNull(errors); + }, + 'should process @import': function(errors, minified) { + assert.equal(minified, 'body{margin:0}div{padding:0}a{color:red}'); + }, + teardown: function() { + assert.equal(this.reqMocks.isDone(), true); + nock.restore(); + } + }, + 'of a resource without protocol': { + topic: function() { + this.reqMocks = nock('http://goalsmashers.com') + .get('/no-protocol.css') + .reply(200, 'div{padding:0}'); + + new CleanCSS().minify('@import url(//goalsmashers.com/no-protocol.css);a{color:red}', this.callback); + }, + 'should not raise errors': function(errors, minified) { + assert.isNull(errors); + }, + 'should process @import': function(errors, minified) { + assert.equal(minified, 'div{padding:0}a{color:red}'); + }, + teardown: function() { + assert.equal(this.reqMocks.isDone(), true); + nock.restore(); + } + }, + 'of a resource available via POST only': { + topic: function() { + this.reqMocks = nock('http://goalsmashers.com') + .post('/computed.css') + .reply(200, 'div{padding:0}'); + + new CleanCSS({ + inliner: { + request: { + method: 'POST' + } + } + }).minify('@import url(http://goalsmashers.com/computed.css);a{color:red}', this.callback); + }, + 'should not raise errors': function(errors, minified) { + assert.isNull(errors); + }, + 'should process @import': function(errors, minified) { + assert.equal(minified, 'div{padding:0}a{color:red}'); + }, + teardown: function() { + assert.equal(this.reqMocks.isDone(), true); + nock.restore(); + } + }, + 'of a remote resource mixed with local ones': { + topic: function() { + var source = '@import url(http://goalsmashers.com/remote.css);@import url(test/data/partials/one.css);'; + this.reqMocks = nock('http://goalsmashers.com') + .get('/remote.css') + .reply(200, 'div{padding:0}'); + + new CleanCSS().minify(source, this.callback); + }, + 'should not raise errors': function(errors, minified) { + assert.isNull(errors); + }, + 'should process @import': function(errors, minified) { + assert.equal(minified, 'div{padding:0}.one{color:red}'); + }, + teardown: function() { + assert.equal(this.reqMocks.isDone(), true); + nock.restore(); + } + }, + 'of a remote resource mixed with local ones but no callback': { + topic: function() { + var source = '@import url(http://goalsmashers.com/remote.css);@import url(test/data/partials/one.css);'; + this.reqMocks = nock('http://goalsmashers.com') + .get('/remote.css') + .reply(200, 'div{padding:0}'); + + var minifier = new CleanCSS(); + var minified = minifier.minify(source); + this.callback(null, minifier, minified); + }, + 'should not raise errors': function(error, minifier) { + assert.isEmpty(minifier.errors); + }, + 'should raise warnings': function(error, minifier) { + assert.equal(minifier.warnings.length, 1); + assert.match(minifier.warnings[0], /no callback given/); + }, + 'should process @import': function(error, minifier, minified) { + assert.equal(minified, '@import url(http://goalsmashers.com/remote.css);.one{color:red}'); + }, + teardown: function() { + assert.equal(this.reqMocks.isDone(), false); + nock.restore(); + } + } +}).export(module); diff --git a/test/unit-test.js b/test/unit-test.js index 66d18f15..d6537229 100644 --- a/test/unit-test.js +++ b/test/unit-test.js @@ -1015,17 +1015,6 @@ title']{display:block}", "@import url(fake.css)", '' ], - 'of a http file': "@import url(http://pro.goalsmashers.com/test.css);", - 'of a https file': [ - "@import url('https://pro.goalsmashers.com/test.css');", - "@import url(https://pro.goalsmashers.com/test.css);" - ], - 'of a remote file with media': "@import url(https://pro.goalsmashers.com/test.css) screen,tv;", - 'of a url starting with //': [ - "@import url(//fonts.googleapis.com/css?family=Lato:400,700,400italic|Merriweather:400,700);", - "@import url(//fonts.googleapis.com/css?family=Lato:400,700,400italic|Merriweather:400,700);" - ], - 'of a remote file via // url with media': "@import url(//pro.goalsmashers.com/test.css) screen,tv;", 'of a directory': [ "@import url(test/data/partials);", ''