├── .gitignore ├── CHANGELOG.textile ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.textile ├── lib ├── rack-offline.rb └── rack │ ├── offline.rb │ └── offline │ ├── config.rb │ └── version.rb ├── rack-offline.gemspec └── spec ├── base_offline_spec.rb ├── cached_offline_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle 2 | public 3 | config.ru 4 | spec/fixture_root 5 | -------------------------------------------------------------------------------- /CHANGELOG.textile: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wycats/rack-offline/355b2ecd88667857b26852a84a297c60fed7fc2d/CHANGELOG.textile -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | group :test do 4 | gem "rspec", "~> 2.0.0.beta.4" 5 | gem "rack-test", :require => "rack/test" 6 | end -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: http://rubygems.org/ 3 | specs: 4 | diff-lcs (1.1.2) 5 | rack (1.1.0) 6 | rack-test (0.5.4) 7 | rack (>= 1.0) 8 | rspec (2.0.0.beta.9) 9 | rspec-core (= 2.0.0.beta.9) 10 | rspec-expectations (= 2.0.0.beta.9) 11 | rspec-mocks (= 2.0.0.beta.9) 12 | rspec-core (2.0.0.beta.9) 13 | rspec-expectations (2.0.0.beta.9) 14 | diff-lcs (>= 1.1.2) 15 | rspec-mocks (2.0.0.beta.9) 16 | 17 | PLATFORMS 18 | ruby 19 | 20 | DEPENDENCIES 21 | rack-test 22 | rspec (~> 2.0.0.beta.4) 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Yehuda Katz 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | -------------------------------------------------------------------------------- /README.textile: -------------------------------------------------------------------------------- 1 | h1. HTML5 Offline 2 | 3 | HTML5 provides two robust offline capabilities already implemented in popular mobile devices, such as the iPhone and Android, and on modern desktop browsers based on the Webkit and Gecko rendering engines. 4 | 5 | h2. Usage 6 | 7 | The easiest way to use Rack::Offline is by using Rails::Offline in a Rails application. 8 | In your router: 9 | 10 |
11 | match "/application.manifest" => Rails::Offline
12 |
13 |
14 | This will automatically cache all JavaScript, CSS, and HTML in your @public@
15 | directory, and will cause the cache to be updated each request in development
16 | mode.
17 |
18 | You can fine-tune the behavior of Rack::Offline by using it directly:
19 |
20 |
21 | offline = Rack::Offline.configure do
22 | cache "images/masthead.png"
23 |
24 | public_path = Rails.public_path
25 | Dir[public_path.join("javascripts/*.js")].each do |file|
26 | cache file.relative_path_from(public_path)
27 | end
28 |
29 | network "/"
30 | end
31 |
32 |
33 | And when used with Rails asset pipeline:
34 |
35 |
36 | if Rails.env.production?
37 | offline = Rack::Offline.configure :cache_interval => 120 do
38 | cache ActionController::Base.helpers.asset_path("application.css")
39 | cache ActionController::Base.helpers.asset_path("application.js")
40 | # cache other assets
41 | network "/"
42 | end
43 | match "/application.manifest" => offline
44 | end
45 |
46 |
47 | You can pass an options Hash into #configure in Rack::Offline:
48 |
49 | |_. name |_. purpose |_. value in Rails::Offline |
50 | | :cache | false means that the browser should download the assets on each request if a connection to the server can be made | the same as config.cache_classes |
51 | | :logger | a logger to send messages to | Rails.logger |
52 | | :root | The location of files listed in the manifest | Rails.public_path |
53 |
54 | h2. Application Cache
55 |
56 | The App Cache allows you to specify that the browser should cache certain files, and ensure that the user can access them even if the device is offline.
57 |
58 | You specify an application's cache with a new @manifest@ attribute on the @html@ element, which must point at a location on the web that serves the manifest. A manifest looks something like this:
59 |
60 | 61 | CACHE MANIFEST 62 | 63 | javascripts/application.js 64 | javascripts/jquery.js 65 | images/masthead.png 66 | 67 | NETWORK: 68 | / 69 |70 | 71 | This specifies that the browser should cache the three files immediately following
CACHE MANIFEST, and require a network connection for all other URLs.
72 |
73 | Unlike HTTP caches, the browser treats the files listed in the manifest as an atomic unit: either it can serve all of them out of the manifest or it needs to update all of them. It will not flush the cache unless the user specifically asks the browser to clear the cache or for security reasons.
74 |
75 | Additionally, the HTML file that supplies the @manifest@ attribute is implicitly in the manifest. This means that the browser can load the HTML file and all its cached assets as a unit, even if the device is offline.
76 |
77 | In short, the App Cache is a much stickier, atomic cache. After storing an App Cache, the browser takes the following (simplified) steps in subsequent requests:
78 |
79 | # Immediately serve the HTML file and its assets from the App Cache. This happens
80 | whether or not the device is online
81 | # If the device is offline, treat any resources not specified in the App Cache
82 | as 404s. This means that images will appear broken, for instance, unless you
83 | make sure to include them in the App Cache.
84 | # Asynchronously try to download the file specified in the @manifest@ attribute
85 | # If it successfully downloads the file, compare the manifest byte-for-byte with
86 | the stored manifest.
87 | ** If it is identical, do nothing.
88 | ** If it is not identical, download (again, asynchronously), all assets specified
89 | in the manifest
90 | # Along the way, fire a number of JavaScript events. For instance, if the browser
91 | updates the cache, fire an @updateready@ event. You can use this event to
92 | display a notice to the user that the version of the HTML they are using is
93 | out of date
94 |
95 | h3. App Cache Considerations
96 |
97 | The first browser hit after you change the HTML will always serve up stale HTML
98 | and JavaScript. You can mitigate this in two obvious ways:
99 |
100 | # Treat your mobile web app as an API consumer and make sure that your app
101 | can support a "client" that's one version older than the current version
102 | of the API.
103 | # Force the user to reload the HTML to see newer data. You can detect this
104 | situation by listening for the @updateready@ event
105 |
106 | A good recommendation is to have your server support clients at most one
107 | version old, but force older clients to reload the page to get newer data.
108 |
109 | Regular users of your application will receive updates through normal usage,
110 | and will never be forced to update. Irregular users may be forced to update
111 | if they pick up the application months after they last used in. In all, a
112 | pretty good trade-off.
113 |
114 | While this may seem cumbersome at first, it makes it possible for your users
115 | to browse around your application more naturally when they have flaky
116 | connections, because the process of updating assets (including HTML)
117 | always happens in the background.
118 |
119 | h3. Updating the App Cache
120 |
121 | You will need to make sure that you update the cache manifest when any of
122 | the underlying assets change.
123 |
124 | Rack::Offline handles this using two strategies:
125 |
126 | # In development, it generates a SHA hash based on the timestamp for each
127 | request. This means that the browser will always interpret the cache
128 | manifest as stale. Note that, as discussed in the previous section,
129 | you will need to reload the page twice to get updated assets.
130 | # In production, it generates a SHA hash once based on the contents of
131 | all the assets in the manifest. This means that the cache manifest will
132 | not be considered stale unless the underlying assets change.
133 |
134 | Rails::Offline caches all JavaScript, CSS, images and HTML
135 | files in @public@ and uses @config.cache_classes@ to determine which of
136 | the above modes to use. In Rails, you can get more fine-grained control
137 | over the process by using Rack::Offline directly.
138 |
139 | h2. Local Storage
140 |
141 | Browsers that support the App Cache also support Local Storage, from the
142 | HTML5 Web Storage Spec. IE8 and above also support Local
143 | Storage.
144 |
145 | Local Storage is a JavaScript API to an extremely simple key-value store.
146 |
147 | It works the same as accessing an Object in JavaScript, but persists the
148 | value across sessions.
149 |
150 | 151 | localStorage.title = "Welcome!" 152 | localStorage.title //=> "Welcome!" 153 | 154 | delete localStorage.title 155 | localStorage.title //=> undefined 156 |157 | 158 | Browsers can offer different amounts of storage using this API. The 159 | iPhone, for instance, offers 5MB of storage, after which it asks the 160 | user for permission to store an additional 10MB. 161 | 162 | You can reclaim storage from a key by
deleteing it or
163 | by overwriting its value. You can also enumerate over all keys in
164 | the localStorage using the normal JavaScript for/in
165 | API.
166 |
167 | In combination with the App Cache, you can use Local Storge to store
168 | data on the device, making it possible to show stale data to your
169 | users even if no connection is available (or in flaky connection
170 | scenarios).
171 |
172 | h2. Basic JavaScript Strategy
173 |
174 | You can implement a simple offline application using only a few
175 | lines of JavaScript. For simplicity, I will use jQuery, but you
176 | can easily implement this in pure JavaScript as well. The
177 | example is heavily commented, but the total number of lines of
178 | actual JavaScript is quite small.
179 |
180 |
181 | jQuery(function($) {
182 | // Declare a function that can take a JS object and
183 | // populate our HTML. Because we used the App Cache
184 | // the HTML will be present regardless of online status
185 | var updateArticles = function(object) {
186 | template = $("#articles")
187 | localStorage.articles = JSON.stringify(object);
188 | $("#article-list").html(template.render(object));
189 | }
190 |
191 | // Create a flag so we don't poll the server twice
192 | // at once
193 | var updating = false;
194 |
195 | // Create a function that will ask the server for
196 | // updates to the article list
197 | var remoteUpdate = function() {
198 | // Don't ping the server again if we're in the
199 | // process of updating
200 | if(updating) return;
201 |
202 | updating = true;
203 |
204 | $("#loading").show();
205 | $.getJSON("/article_list.json", function(json) {
206 | updateArticles(json);
207 | $("#loading").hide();
208 | updating = false;
209 | });
210 | }
211 |
212 | // If we have "articles" in the localStorage object,
213 | // update the HTML with the stale articles. Even if
214 | // the user never gets online, they will at least
215 | // see the stale content
216 | if(localStorage.articles) updateArticles(JSON.parse(localStorage.articles));
217 |
218 | // If the user was offline, and goes online, ask
219 | // the server for updates
220 | $(window).bind("online", remoteUpdate);
221 |
222 | // If the user is online, ask for updates now
223 | if(window.navigator.onLine) remoteUpdate();
224 | })
225 |
226 |
--------------------------------------------------------------------------------
/lib/rack-offline.rb:
--------------------------------------------------------------------------------
1 | require "rack/offline"
2 |
3 | module Rails
4 | class Offline < ::Rack::Offline
5 | def self.call(env)
6 | @app ||= new
7 | @app.call(env)
8 | end
9 |
10 | def initialize(options = {}, app = Rails.application, &block)
11 | config = app.config
12 | root = config.paths['public'].first
13 | block = cache_block(Pathname.new(root)) unless block_given?
14 |
15 | opts = {
16 | :cache => config.cache_classes,
17 | :root => root,
18 | :logger => Rails.logger
19 | }.merge(options)
20 |
21 | super(opts, &block)
22 | end
23 |
24 | private
25 |
26 | def cache_block(root)
27 | Proc.new do
28 | if Rails.version >= "3.1" && Rails.configuration.assets.enabled
29 | files = Dir[
30 | "#{root}/**/*.html",
31 | "#{root}/assets/**/*.{js,css,jpg,png,gif}"]
32 | else
33 | files = Dir[
34 | "#{root}/**/*.html",
35 | "#{root}/stylesheets/**/*.css",
36 | "#{root}/javascripts/**/*.js",
37 | "#{root}/images/**/*.*"]
38 | end
39 |
40 | files.each do |file|
41 | cache Pathname.new(file).relative_path_from(root)
42 | end
43 |
44 | network "*"
45 | end
46 | end
47 | end
48 | end
--------------------------------------------------------------------------------
/lib/rack/offline.rb:
--------------------------------------------------------------------------------
1 | require "rack/offline/config"
2 | require "rack/offline/version"
3 | require "digest/sha2"
4 | require "logger"
5 | require "pathname"
6 | require 'uri'
7 |
8 | module Rack
9 | class Offline
10 | def self.configure(*args, &block)
11 | new(*args, &block)
12 | end
13 |
14 | # interval in seconds used to compute the cache key when in uncached mode
15 | # which can be set by passing in options[:cache_interval]
16 | # note: setting it to 0 or a low value will change the cache key every request
17 | # which means the manifest will never successfully download
18 | # (since it gets downloaded again at the end)
19 | UNCACHED_KEY_INTERVAL = 10
20 |
21 | def initialize(options = {}, &block)
22 | @cache = options[:cache]
23 |
24 | @logger = options[:logger] || begin
25 | ::Logger.new(STDOUT).tap {|logger| logger.level = 1 }
26 | end
27 |
28 | @root = Pathname.new(options[:root] || Dir.pwd)
29 |
30 | if block_given?
31 | @config = Rack::Offline::Config.new(@root, &block)
32 | end
33 |
34 | if @cache
35 | raise "In order to run Rack::Offline in cached mode, " \
36 | "you need to supply a root so Rack::Offline can " \
37 | "calculate a hash of the files." unless @root
38 | precache_key!
39 | else
40 | @cache_interval = (options[:cache_interval] || UNCACHED_KEY_INTERVAL).to_i
41 | end
42 | end
43 |
44 | def call(env)
45 | key = @key || uncached_key
46 |
47 | body = ["CACHE MANIFEST"]
48 | body << "# #{key}"
49 | @config.cache.each do |item|
50 | body << URI.escape(item.to_s)
51 | end
52 |
53 | unless @config.network.empty?
54 | body << "" << "NETWORK:"
55 | @config.network.each do |item|
56 | body << URI.escape(item.to_s)
57 | end
58 | end
59 |
60 | unless @config.fallback.empty?
61 | body << "" << "FALLBACK:"
62 | @config.fallback.each do |namespace, url|
63 | body << "#{namespace} #{URI.escape(url.to_s)}"
64 | end
65 | end
66 |
67 | @logger.debug body.join("\n")
68 |
69 | [200, {"Content-Type" => "text/cache-manifest"}, [body.join("\n")]]
70 | end
71 |
72 | private
73 |
74 | def precache_key!
75 | hash = @config.cache.sort!.map do |item|
76 | path = @root.join(item)
77 | Digest::SHA2.hexdigest(path.read) if ::File.file?(path)
78 | end
79 |
80 | @key = Digest::SHA2.hexdigest(hash.join)
81 | end
82 |
83 | def uncached_key
84 | now = Time.now.to_i - Time.now.to_i % @cache_interval
85 | Digest::SHA2.hexdigest(now.to_s)
86 | end
87 | end
88 | end
--------------------------------------------------------------------------------
/lib/rack/offline/config.rb:
--------------------------------------------------------------------------------
1 | module Rack
2 | class Offline
3 | class Config
4 | def initialize(root, &block)
5 | @cache = []
6 | @network = []
7 | @fallback = {}
8 | @root = root
9 | instance_eval(&block) if block_given?
10 | end
11 |
12 | def cache(*names)
13 | @cache.concat(names)
14 | end
15 |
16 | def network(*names)
17 | @network.concat(names)
18 | end
19 |
20 | def fallback(hash = {})
21 | @fallback.merge!(hash)
22 | end
23 |
24 | def root
25 | @root
26 | end
27 | end
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/lib/rack/offline/version.rb:
--------------------------------------------------------------------------------
1 | module Rack
2 | class Offline
3 | VERSION = "0.6.4"
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/rack-offline.gemspec:
--------------------------------------------------------------------------------
1 | path = File.expand_path("../lib", __FILE__)
2 | $:.unshift(path) unless $:.include?(path)
3 | require "rack/offline/version"
4 |
5 | Gem::Specification.new do |s|
6 | s.platform = Gem::Platform::RUBY
7 | s.name = 'rack-offline'
8 | s.version = Rack::Offline::VERSION
9 | s.summary = 'A Rack toolkit for working with offline applications'
10 | s.description = 'A Rack endpoint that generates cache manifests and other useful ' \
11 | 'HTML5 offline utilities that are useful on the server-side. ' \
12 | 'Rack::Offline also provides a conventional Rails endpoint (' \
13 | 'Rails::Offline) that configures Rack::Offline using expected ' \
14 | 'Rails settings'
15 |
16 | s.author = 'Yehuda Katz'
17 | s.email = 'wycats@gmail.com'
18 | s.homepage = 'http://www.yehudakatz.com'
19 | s.rubyforge_project = 'rack-offline'
20 |
21 | s.files = Dir['CHANGELOG', 'README', 'LICENSE', 'lib/**/*']
22 | s.require_path = 'lib'
23 | end
24 |
--------------------------------------------------------------------------------
/spec/base_offline_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | describe "Generating a basic manifest" do
4 | include Rack::Test::Methods
5 |
6 | self.app = Rack::Offline.configure do
7 | cache "images/masthead.png"
8 | end
9 |
10 | it_should_behave_like "a cache manifest"
11 |
12 | it "doesn't contain a network section" do
13 | body.should_not =~ %r{^NETWORK:}
14 | end
15 |
16 | it "doesn't contain a fallback section" do
17 | body.should_not =~ %r{^FALLBACK:}
18 | end
19 |
20 | describe "cache-busting comment" do
21 | context "if no interval is specified" do
22 | self.app = Rack::Offline.configure do
23 | cache "images/masthead.png"
24 | end
25 |
26 | it_should_behave_like "uncached cache manifests"
27 | end
28 |
29 | context "if an interval is specified" do
30 | INTERVAL = 15
31 | self.app = Rack::Offline.configure(:cache_interval => INTERVAL) do
32 | cache "images/masthead.png"
33 | end
34 |
35 | before do
36 | @interval = INTERVAL
37 | end
38 | it_should_behave_like "uncached cache manifests"
39 | end
40 | end
41 | end
--------------------------------------------------------------------------------
/spec/cached_offline_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 | require "fileutils"
3 |
4 | describe "Generating a manifest in cached mode" do
5 | include Rack::Test::Methods
6 |
7 | def self.new_app(&block)
8 | root = File.expand_path("../fixture_root", __FILE__)
9 | Rack::Offline.configure(:root => root, :cache => true) do
10 | cache "hello.html"
11 | cache "hello.css"
12 | cache "javascripts/hello.js"
13 | instance_eval(&block) if block_given?
14 | end
15 | end
16 |
17 | def self.setup_fixtures
18 | fixture_root = Pathname.new(File.expand_path("../fixture_root", __FILE__))
19 | FileUtils.rm_rf(fixture_root)
20 | FileUtils.mkdir_p(fixture_root)
21 |
22 | File.open(fixture_root.join("hello.css"), "w") do |file|
23 | file.puts "#hello {\n display: false\n}\n"
24 | end
25 |
26 | File.open(fixture_root.join("hello.html"), "w") do |file|
27 | file.puts "\n\n"
28 | end
29 |
30 | FileUtils.mkdir_p(fixture_root.join("javascripts"))
31 | File.open(fixture_root.join("javascripts/hello.js"), "w") do |file|
32 | file.puts "var x = 1;"
33 | end
34 | end
35 |
36 | def self.reload_server
37 | setup_fixtures
38 | self.app = new_app
39 | end
40 |
41 | def reload_server
42 | self.class.reload_server
43 | end
44 |
45 | before :all do
46 | reload_server
47 | end
48 |
49 | before do
50 | get "/"
51 | end
52 |
53 | it_should_behave_like "a cache manifest"
54 |
55 | it "returns the same cache-busting header every time" do
56 | cache_buster = body[/^# .{64}$/]
57 | get "/"
58 | body[/^# .{64}$/].should == cache_buster
59 | end
60 |
61 | it "updates the cache-busting header if the files change and the server restarts" do
62 | cache_buster = body[/^# .{64}$/]
63 |
64 | root = File.expand_path("../fixture_root", __FILE__)
65 | File.open("#{root}/hello.css", "w") {|file| file.puts "OMG"}
66 |
67 | self.class.app = self.class.new_app
68 |
69 | with_session :secondary do
70 | get "/"
71 | body[/^# .{64}$/].should_not == cache_buster
72 | end
73 |
74 | reload_server
75 | end
76 |
77 | it "doesn't contain a network section" do
78 | body.should_not =~ %r{^NETWORK:}
79 | end
80 |
81 | it "doesn't contain a fallback section" do
82 | body.should_not =~ %r{^FALLBACK:}
83 | end
84 |
85 | it "does contain a network section" do
86 | self.class.app = self.class.new_app{ network "/" }
87 | with_session :new_app_with_network do
88 | get "/" do
89 | body.should =~ %r{^NETWORK:}
90 | end
91 | end
92 | end
93 |
94 | it "does contain a fallback section" do
95 | self.class.app = self.class.new_app{ fallback("/" => "/offline.html") }
96 | with_session :new_app_with_offline do
97 | get "/"
98 | body.should =~ %r{^FALLBACK:}
99 | end
100 | end
101 |
102 | end
103 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | require "rubygems"
2 | require "bundler"
3 | Bundler.setup
4 |
5 | $:.unshift File.expand_path("../../lib", __FILE__)
6 |
7 | require "rack/offline"
8 | Bundler.require(:test)
9 |
10 | module Rack::Test::Methods
11 | def self.included(klass)
12 | class << klass
13 | attr_accessor :app
14 | end
15 | end
16 |
17 | def body
18 | last_response.body
19 | end
20 |
21 | def status
22 | last_response.status
23 | end
24 |
25 | def headers
26 | last_response.headers
27 | end
28 |
29 | def app
30 | self.class.app
31 | end
32 | end
33 |
34 | shared_examples_for "a cache manifest" do
35 | before do
36 | get "/"
37 | end
38 |
39 | it "returns the response as text/cache-manifest" do
40 | headers["Content-Type"].should == "text/cache-manifest"
41 | end
42 |
43 | it "returns a 200 status code" do
44 | status.should == 200
45 | end
46 |
47 | it "includes the text CACHE MANIFEST" do
48 | body.should =~ /\ACACHE MANIFEST\n/
49 | end
50 |
51 | it "includes a cache-busting comment" do
52 | body.should =~ %r{^# .{64}$}
53 | end
54 | end
55 |
56 | shared_examples_for "uncached cache manifests" do
57 | before do
58 | @interval ||= Rack::Offline::UNCACHED_KEY_INTERVAL
59 | Time.stub(:now).and_return(Time.at(@interval))
60 | get "/"
61 | end
62 |
63 | it "returns the same cache-busting comment within a given interval" do
64 | cache_buster = body[/^# .{64}$/]
65 | Time.stub(:now).and_return(Time.at(2 * @interval - 1))
66 | get "/"
67 | body[/^# .{64}$/].should == cache_buster
68 | end
69 |
70 | it "returns a different cache-busting comment after the interval" do
71 | Time.stub(:now).and_return(Time.at(@interval))
72 | cache_buster = body[/^# .{64}$/]
73 | Time.stub(:now).and_return(Time.at(2 * @interval))
74 | get "/"
75 | body[/^# .{64}$/].should_not == cache_buster
76 | end
77 | end
--------------------------------------------------------------------------------