Fixes #85 - adds resolving protocol @import statements.
authorGoalSmashers <jakub@goalsmashers.com>
Tue, 10 Dec 2013 12:14:54 +0000 (13:14 +0100)
committerGoalSmashers <jakub@goalsmashers.com>
Mon, 6 Jan 2014 18:37:35 +0000 (19:37 +0100)
* 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.

12 files changed:
History.md
bin/cleancss
lib/clean.js
lib/images/url-rewriter.js
lib/imports/inliner.js
package.json
test/batch-test.js
test/bench.js
test/binary-test.js
test/module-test.js
test/protocol-imports-test.js [new file with mode: 0644]
test/unit-test.js

index 82d5d92..6b80334 100644 (file)
@@ -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.
index 6fe291c..c5f80db 100755 (executable)
@@ -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) {
index 2c3b03f..3d8ddee 100644 (file)
@@ -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;
index 14df441..af17d5b 100644 (file)
@@ -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' ?
index 95bd13f..8e26ca8 100644 (file)
@@ -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
index d8d5e61..1edc555 100644 (file)
@@ -38,6 +38,7 @@
   },
   "devDependencies": {
     "jshint": "2.4.x",
+    "nock": "0.25.x",
     "vows": "0.7.x"
   },
   "jshintConfig": {
index b5194e8..42363df 100644 (file)
@@ -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]);
+        });
       });
     };
   });
index c821605..09fb0db 100644 (file)
@@ -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);
+});
index d4b4a7a..bbf9617 100644 (file)
@@ -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();
+    }
+  })
 });
index 8cce9fa..7290437 100644 (file)
@@ -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 (file)
index 0000000..f0d1a3b
--- /dev/null
@@ -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);
index 66d18f1..d653722 100644 (file)
@@ -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);",
       ''