├── .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 --------------------------------------------------------------------------------