diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01d8f04..b451c68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,3 +19,15 @@ jobs: ruby-version: ${{ matrix.ruby }} bundler-cache: true - run: bundle exec rake css + + # Capybara integration test for the utility-nav server clock. Needs headless + # Chrome (preinstalled on ubuntu-latest) and Rails 8 (Ruby >= 3.2). + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + - run: bundle exec rake test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ce9cfc3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/Gemfile.lock +/pkg/ +/src/ +/test/dummy/tmp/ +/test/dummy/log/ diff --git a/Gemfile b/Gemfile index b02e4a8..49e2c88 100644 --- a/Gemfile +++ b/Gemfile @@ -7,3 +7,20 @@ group :development, :test do gem 'rake' gem 'sassc' end + +# Dummy ActiveAdmin app (test/dummy) that hosts the Capybara integration test +# (`rake test`) and sources the neutral README screenshot. Sprockets + +# sassc-rails is the pipeline AA 3.x targets (it emits `//= require` directives +# and SCSS). Rails 8 needs Ruby >= 3.2, so keep these out of the 3.1 bundle — +# `rake css` still runs on 3.1 without them. +if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.2') + group :development, :test do + gem 'rails', '~> 8.0' + gem 'sprockets-rails' + gem 'sassc-rails' + gem 'sqlite3' + gem 'puma' + gem 'capybara' + gem 'cuprite' + end +end diff --git a/README.md b/README.md index 00f5399..1b7149b 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,52 @@ The control is a link with no destination, so Tab reaches it and Enter and Space operate it. Changing the theme in one tab applies it in the others. +### Disabling dark mode + +To ship light-only, two manual steps (there is no build flag): + +- Don't include `wigu/theme_toggle` in your JS manifest. That removes the switch + and the automatic `data-theme` switching it drives. +- Pin the document to light: add `data-theme="light"` to the `` element + (e.g. by overriding the ActiveAdmin layout), so the + `@media (prefers-color-scheme: dark)` styles never apply. + +### Server clock + +The `#utility_nav` now carries a ticking server clock — two lines, time + zone +over the date — rendered by the theme's `server_clock.js` and injected per +namespace by its Ruby backend (`ActiveAdminTheme.install!`), with the logout +button beside it. + +The backend injects only the markup; load the script the same way as the +theme switch, or the nav shows a static one-line label and never ticks: + +```js +// app/assets/javascripts/active_admin.js +//= require wigu/server_clock + +// or, as an npm module +import "@activeadmin-plugins/active_admin_theme/src/server_clock"; +``` + +[![Two-line server clock in the utility nav](./img/clock.png)](./img/clock.png) + +The theme also keeps a logout item in the nav. If your app has no logout route +(custom auth) or you deliberately run a logout-free nav, use ActiveAdmin's own +per-namespace switch — the theme honours it: + +```ruby +# config/initializers/active_admin.rb +config.logout_link_path = nil +``` + +The restored current-user item has a theme switch (ActiveAdmin offers none +short of unsetting `current_user_method`, which breaks authorization): + +```ruby +ActiveAdminTheme.add_current_user = false +``` + ### Upgrading #### 3.1.0 diff --git a/Rakefile b/Rakefile index 6ae6529..b090a1d 100644 --- a/Rakefile +++ b/Rakefile @@ -5,4 +5,17 @@ task :css do ruby "test/css_check.rb" end +desc "Capybara integration test: the server clock renders + ticks (needs Chrome)" +task :test do + ruby "test/clock_test.rb" +end + +desc "Regenerate the neutral README screenshot img/clock.png (needs Chrome)" +task :screenshot do + ruby "test/screenshot.rb" +end + +# css only: :test needs the Rails/Capybara stack the Gemfile excludes on +# Ruby 3.1 (the gem's own minimum) plus a local Chrome. Run `rake test` +# explicitly where both are available; CI names its tasks per job anyway. task default: :css diff --git a/app/assets/javascripts/wigu/server_clock.js b/app/assets/javascripts/wigu/server_clock.js new file mode 100644 index 0000000..b7af4a5 --- /dev/null +++ b/app/assets/javascripts/wigu/server_clock.js @@ -0,0 +1,171 @@ +// Server clock for the utility navigation. Shows the server's time ticking once +// a second in li#servertime inside #utility_nav, where the stylesheet styles it. +// +// The instant comes from a [data-servertime] element the host app renders — the +// body in some apps, a footer element in others — holding +// "YYYY MM DD HH MM SS ZONE" (space-separated, exactly what Ruby's +// Time#strftime('%Y %m %d %H %M %S %Z') produces). The clock keeps that server +// instant and advances it by the client's own elapsed time, so a skewed browser +// clock does not throw it off. With no such element it renders nothing: a +// fabricated client clock would bypass every server-side suppression (anonymous +// visitors, hosts without current_user, consumer opt-outs) and show browser +// time masquerading as the server clock. +// +// Vanilla, no deps, mounts itself into #utility_nav. Turbo/Turbolinks swap the +// body without firing DOMContentLoaded, so the clock re-reads the fresh +// [data-servertime] on their load events and re-queries the DOM every tick +// instead of holding node references — a held reference goes detached on the +// first navigation and the visible clock freezes. +(function () { + "use strict"; + + var serverTime, clientTime, zone, fmt; + var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + + function pad2(n) { + return (n < 10 ? "0" : "") + n; + } + + function ready(fn) { + if (document.readyState !== "loading") fn(); + else document.addEventListener("DOMContentLoaded", fn); + } + + // (Re)read the server instant from the current document. + function sync() { + // Prefer the theme's own span inside the nav: a host mid-migration may + // still render its old body[data-servertime] (possibly in another format), + // which would win on first-match-in-tree-order and shadow the fresh + // per-request instant. Fall back to anywhere in the document for hosts + // that only render the attribute on the body or a footer element. + var nav = document.getElementById("utility_nav"); + var source = (nav && nav.querySelector("[data-servertime]")) || + document.querySelector("[data-servertime]"); + + // Preferred source: an absolute instant plus the server's IANA zone + // (rendered by the gem's Ruby backend). Intl re-applies the zone's offset + // on every tick, so a long-lived page follows the server through its own + // DST transitions — the wall-clock path below keeps the offset and label + // frozen from page load. + fmt = null; + serverTime = undefined; // no source on this page: tick() renders nothing + var epoch = source && source.getAttribute("data-servertime-epoch"); + var tz = source && source.getAttribute("data-servertime-zone"); + if (epoch && tz && window.Intl && + Intl.DateTimeFormat.prototype.formatToParts) { + try { + fmt = new Intl.DateTimeFormat("en-US", { + timeZone: tz, hour12: false, hourCycle: "h23", + year: "numeric", month: "short", day: "2-digit", + hour: "2-digit", minute: "2-digit", second: "2-digit", + timeZoneName: "short" + }); + serverTime = epoch * 1000; + // A malformed epoch is truthy but NaN; formatToParts(new Date(NaN)) + // would throw on every tick. Drop to the wall-clock path instead. + if (!isFinite(serverTime)) fmt = null; + } catch (e) { + fmt = null; // unknown zone name: fall through to the wall-clock path + } + } + + var raw = !fmt && source && source.getAttribute("data-servertime"); + + if (raw) { + var bits = raw.split(" "); + // A fixed epoch via Date.UTC, NOT new Date(y,m,d,...): local-zone + // parsing shifts a server instant that falls into the client's DST gap, + // and a tab left open across a client transition jumps the display an + // hour while the server had none. The zone label is carried verbatim. + serverTime = Date.UTC(bits[0], bits[1] - 1, bits[2], bits[3], bits[4], bits[5]); + // No fabricated label: a missing %Z just renders no zone. + zone = bits[6] || ""; + } + clientTime = Date.now(); + tick(); + } + + // The two display lines for this li, created on first use and reused every + // later tick. The server-rendered inner span [data-servertime] is the data + // source for later sync() calls (turbo:load, bfcache restores) — never + // overwrite it; hide it and append the lines beside it. textContent only, + // no innerHTML: the zone token of a host-rendered attribute is untrusted + // text and must not be able to become markup — and skipping the per-second + // innerHTML re-parse stops the node churn too. + function lines(li) { + var time = li.querySelector(".clock-time"); + if (time) return { time: time, date: li.querySelector(".clock-date") }; + var data = li.querySelector("[data-servertime]"); + // Append target: the outer Arbre span when the gem rendered the item, the + // li's own span otherwise. No inline colour — the span inherits the nav's + // text colour from the stylesheet cascade. + var host = data ? data.parentNode : li.querySelector("span"); + if (!host) { + host = document.createElement("span"); + li.appendChild(host); + } + if (data) data.style.display = "none"; + time = document.createElement("span"); + time.className = "clock-time"; + var date = document.createElement("span"); + date.className = "clock-date"; + host.appendChild(time); + host.appendChild(date); + return { time: time, date: date }; + } + + function tick() { + var nav = document.getElementById("utility_nav"); + // isFinite, not an undefined check: a host-rendered data-servertime in an + // unexpected format parses to NaN, which must render nothing — not a + // ticking "NaN:NaN:NaN". + if (!nav || !isFinite(serverTime)) return; + + // Reuse an existing li (idempotent across re-renders); never duplicate. + var li = nav.querySelector("#servertime"); + if (!li) { + li = document.createElement("li"); + li.id = "servertime"; + // Mark it as ours, so the ownership check below keeps passing. + li.setAttribute("data-theme-clock", ""); + nav.appendChild(li); + } else if (!li.hasAttribute("data-theme-clock") && + !li.querySelector("[data-servertime]")) { + // The li belongs to the host — their own servertime menu item, e.g. + // rendered as a plain link. Appending display lines would show the + // host's label and a second clock side by side in one item. + return; + } + var out = lines(li); + + // Two lines, as in yeti-web: time + timezone on top (larger), year month + // day below (smaller). The stylesheet sizes .clock-time / .clock-date. + var now = new Date(serverTime + (Date.now() - clientTime)); + var time, date; + if (fmt) { + // serverTime is an absolute instant; Intl renders it in the server's + // zone, offset and abbreviation recomputed per tick. + var p = {}; + fmt.formatToParts(now).forEach(function (part) { p[part.type] = part.value; }); + time = p.hour + ":" + p.minute + ":" + p.second + " " + p.timeZoneName; + date = p.year + " " + p.month + " " + p.day; + } else { + // UTC getters only — serverTime is a fixed epoch holding a wall clock, + // so the client's zone and DST rules never touch the math. + time = pad2(now.getUTCHours()) + ":" + pad2(now.getUTCMinutes()) + ":" + + pad2(now.getUTCSeconds()) + (zone ? " " + zone : ""); + date = now.getUTCFullYear() + " " + MONTHS[now.getUTCMonth()] + " " + + pad2(now.getUTCDate()); + } + out.time.textContent = time; + out.date.textContent = date; + } + + ready(function () { + sync(); + setInterval(tick, 1000); + }); + document.addEventListener("turbo:load", sync); + document.addEventListener("turbolinks:load", sync); +})(); diff --git a/app/assets/stylesheets/wigu/active_admin_theme.scss b/app/assets/stylesheets/wigu/active_admin_theme.scss index 60b11b5..d6194dc 100644 --- a/app/assets/stylesheets/wigu/active_admin_theme.scss +++ b/app/assets/stylesheets/wigu/active_admin_theme.scss @@ -249,6 +249,11 @@ $theme-icon-auto: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/s $theme-icon-light: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.4' stroke-linecap='round'%3E%3Ccircle cx='8' cy='8' r='3.1'/%3E%3Cpath d='M8 .9v1.8M8 13.3v1.8M.9 8h1.8M13.3 8h1.8M2.9 2.9l1.3 1.3M11.8 11.8l1.3 1.3M13.1 2.9l-1.3 1.3M4.2 11.8l-1.3 1.3'/%3E%3C/svg%3E")!default; $theme-icon-dark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M13.4 9.6A5.9 5.9 0 0 1 6.4 2.6a5.9 5.9 0 1 0 7 7z'/%3E%3C/svg%3E")!default; +// Logout drawn as a sign-out glyph instead of the word "Logout", so it matches +// the icon toggle beside it. Masked like the theme icons above, so it takes +// currentColor and follows $skinMenuTextColor; a consumer can override the var. +$logout-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6.5 2.5H3.2v11h3.3'/%3E%3Cpath d='M12.8 8H6'/%3E%3Cpath d='M9.5 5 12.8 8l-3.3 3'/%3E%3C/svg%3E")!default; + // A wrong-typed override is legal SassScript, so it compiles without a word and // emits CSS the browser drops — `border-bottom: none solid #5ea3d3`, // `padding: 10 12px`. The rule silently vanishes and ActiveAdmin's own value @@ -435,6 +440,17 @@ body.active_admin { border: none; text-shadow: none; background-image: none; + + // A wide index table scrolls the WHOLE page sideways (#wrapper is + // display:table, min-width:100%). Keep the header pinned on screen and at the + // viewport width while that happens, so it doesn't stretch to the table's + // width (which spread the menu out under the utility nav and overlapped it). + // The header keeps ActiveAdmin's natural layout, so it still grows to fit a + // wrapped menu. + position: sticky; + left: 0; + width: 100vw; + box-sizing: border-box; h1.site_title { color: $skinMainSecondColor; font-size: 16px; @@ -465,16 +481,34 @@ body.active_admin { // tabs: drop the pill padding, and on hover highlight by text colour only — no // background block. The clock has no click action, so it gets no hover at all. #utility_nav { - // Pin to the viewport's right edge so the clock / theme toggle / logout - // stay reachable while a wide table scrolls the page sideways. - position: sticky; - right: 0; + // Sits at the top-right of the header; the header itself is pinned on screen + // (sticky, see #header above) so the clock / theme toggle / logout stay + // reachable while a wide table scrolls the page sideways. z-index: 2; + white-space: nowrap; > li > a { padding: 0 5px; } > li:hover > a, > li.current > a { background-color: transparent; } > li#servertime:hover > a { color: #ffffff; } + // Server clock (server_clock.js), rendered as two compact lines like + // yeti-web: time + timezone (larger) over year/month/day (smaller). A + // right-aligned flex column sized to the nav band (~24px) so both lines + // fit the header height unchanged. Monospace + tabular figures keep the + // ticking time a constant width, so the adjacent logout doesn't jitter. + > li#servertime > span { + display: inline-flex; + flex-direction: column; + justify-content: center; + align-items: flex-end; + vertical-align: middle; + height: 24px; + font-family: 'SFMono-Regular', Menlo, Consolas, 'Liberation Mono', monospace; + font-variant-numeric: tabular-nums; + } + > li#servertime .clock-time { font-size: 12px; line-height: 1.1; } + > li#servertime .clock-date { font-size: 9px; line-height: 1.1; } + // The theme switch (app/assets/javascripts/wigu/theme_toggle.js). The // script only writes data-mode; the glyph is drawn here, so a project can // swap it for its own icon font without forking the script. Masked rather @@ -511,6 +545,35 @@ body.active_admin { -webkit-mask-image: $theme-icon-dark; mask-image: $theme-icon-dark; } + + // Logout as an icon ($logout-icon), to match the toggle beside it. + // font-size:0 hides the "Logout" text visually; the accessible name is + // the aria-label the gem's Ruby puts on the anchor, because zero-sized + // text is pruned from the accessibility tree by some screen readers + // (the toggle gets its aria-label from theme_toggle.js the same way). + // The masked glyph follows the bar's text colour. The real logout + // link/method from add_logout_button_to_menu is untouched. + // Scoped to a.wigu-logout — the class the gem's Ruby puts on the item + // it adds — so a consumer-rendered logout keeps its own label instead + // of collapsing to an unlabeled glyph. + > li#logout:has(> a.wigu-logout) { display: inline-flex; align-items: center; vertical-align: middle; } + > li#logout > a.wigu-logout { + display: flex; + align-items: center; + font-size: 0; + opacity: 0.75; + + &:before { + content: ""; + display: block; + width: 16px; + height: 16px; + background-color: currentColor; + -webkit-mask: #{$logout-icon} center / contain no-repeat; + mask: #{$logout-icon} center / contain no-repeat; + } + &:hover, &:focus { opacity: 1; } + } } ul.tabs { li.has_nested { @@ -1716,6 +1779,12 @@ input[type='radio'] { color: var(--aa-text); } + // Give the content column its own block-formatting context so #index_footer's + // `clear: both` (below) only clears the pagination floats INSIDE the table + // area — not the floated #sidebar. Without this, a tall filter sidebar pushes + // the pagination far below a short table (it clears the sidebar's float too). + #main_content { display: flow-root; } + // Index footer (per-page selector, pagination, "displaying…" info, download // links): lay them out in one left-packed, vertically-centred row. The gem // floats them, so they sat top-aligned and, having different heights, looked diff --git a/img/clock.png b/img/clock.png new file mode 100644 index 0000000..332099b Binary files /dev/null and b/img/clock.png differ diff --git a/lib/active_admin_theme.rb b/lib/active_admin_theme.rb index aca44b7..5c1ead0 100644 --- a/lib/active_admin_theme.rb +++ b/lib/active_admin_theme.rb @@ -3,6 +3,132 @@ module ActiveAdminTheme module Rails class Engine < ::Rails::Engine + # Register the server clock once AA is available. server_clock.js (shipped + # in this gem) ticks li#servertime in #utility_nav and reads the server + # instant from [data-servertime]; here we render both server-side, per + # namespace, so every consumer gets the clock without reimplementing it. + # after :load_config_initializers, or hosts loading AA from a config + # initializer (gem "activeadmin", require: false) fail the defined? + # check and silently get no clock — engine initializers otherwise run + # before the host's config/initializers. + initializer "active_admin_theme.server_clock", after: :load_config_initializers do + if defined?(::ActiveAdmin) + ActiveAdminTheme.install! + else + # Evaluated once; a host requiring activeadmin even later (routes.rb, + # an ENV-gated engine) gets no theme at all — say so instead of + # silently shipping a README-promised feature that never appears. + ::Rails.logger&.warn( + "active_admin_theme: ActiveAdmin was not loaded when the engine " \ + "initialized; the utility-nav clock and logout handling are " \ + "disabled. Require \"active_admin\" no later than config/initializers." + ) + end + end end end -end \ No newline at end of file + + # Opt-out for the restored current-user item. Unlike logout (which AA turns + # off per namespace via logout_link_path = nil), AA has no switch short of + # unsetting current_user_method — which also breaks authorization — and + # Menu has no removal API, so hosts that deliberately run a user-link-free + # header (e.g. display_name would leak an email) set this in an initializer: + # ActiveAdminTheme.add_current_user = false + class << self + attr_accessor :add_current_user + end + self.add_current_user = true + + # Add a utility-nav clock item (and ensure a logout item) to every AA namespace. + # + # data-servertime must be fresh on every request, but only a menu item's + # :label/:url/:if are evaluated per request — :html_options is captured once at + # build time. So the instant is embedded in the label as html_safe markup + # rather than an html_option. The label is "YYYY MM DD HH MM SS ZONE" (exactly + # what server_clock.js parses) on an inner span; the JS overwrites the visible + # text each tick. Timezone comes from the host app's Time.current/Time.zone. + def self.install! + # after_load fires on every ActiveAdmin.load! (each dev reload), and each + # build_menu call registers a before_build callback that MenuCollection#clear! + # never removes — so register once per namespace or callbacks pile up for + # the life of the process. Namespace objects and their MenuCollection + # survive unload! (it only clears the built menus), so once is enough. + seen = {}.compare_by_identity + register = lambda do |namespace| + next if seen[namespace] + seen[namespace] = true + namespace.build_menu(:utility_navigation) do |menu| + # A consumer item with the same id wins, but NOT through these + # guards: before_build unshifts, so the LAST registration runs + # first, and consumer hooks (config/initializers) subscribe before + # this engine's initializer — on a production first load THIS + # callback runs first against an empty menu and the guards all + # pass. What protects consumer overrides is Menu#_add replacing by + # id when their callback runs afterwards. The guards only matter + # from the second dev reload on, when consumer items are already + # present — do not tighten them into the primary mechanism. + unless menu["servertime"] + menu.add id: "servertime", + priority: 99_990, + url: -> {}, + # same guard both default utility items carry — the nav + # stays empty for anonymous visitors, as it was pre-theme + if: :current_active_admin_user?, + label: lambda { + now = Time.current + data = now.strftime("%Y %m %d %H %M %S %Z") + shown = now.strftime("%Y %b %d %H:%M:%S %Z") + # epoch + IANA zone let the JS re-apply the server + # zone's offset on every tick (DST included); the + # wall-clock attribute stays as the documented contract + # for host-rendered elements and older scripts. + # Time.zone is nil under Time.use_zone(nil) — the + # common around_action(user.time_zone) pattern with a + # nil column — while Time.current still works; skip the + # precise attributes then instead of 500ing the page. + tz = Time.zone + precise = tz ? %( data-servertime-epoch="#{now.to_i}" data-servertime-zone="#{tz.tzinfo.identifier}") : "" + %(#{shown}).html_safe + } + end + # Claiming :utility_navigation stops AA's build_default_utility_nav + # (it returns early once the menu exists), so restore the default + # current-user item it would have added. No-op when the host has no + # current_user_method, exactly like the default nav. + if ActiveAdminTheme.add_current_user && !menu["current_user"] + namespace.add_current_user_to_menu(menu) + end + # Opting out is ActiveAdmin's own per-namespace switch, not a theme + # flag: config.logout_link_path = nil makes add_logout_button_to_menu + # a no-op (AA guards on `if logout_link_path`). Hosts with custom + # auth and no logout route — where rendering the default item would + # public_send a missing helper and 500 — set exactly that. + # The wigu-logout class (lands on the — AA passes html_options to + # link_to) scopes the stylesheet's icon treatment to THIS item, so a + # consumer-rendered logout with its own label keeps its text. The + # aria-label is the accessible name: the stylesheet hides the link + # text with font-size: 0, which some screen readers prune from the + # accessibility tree. html_options are captured at menu-build time, so + # the label uses the locale active then — good enough for a nav icon. + unless menu["logout"] + namespace.add_logout_button_to_menu( + menu, 99_999, + class: "wigu-logout", "aria-label": I18n.t("active_admin.logout") + ) + end + end + end + + ::ActiveAdmin.after_load do |app| + app.namespaces.each(®ister) + end + # Production fires after_load exactly once (load! gates on @@loaded), so a + # namespace created later — e.g. another engine's later-subscribed hook + # registering a resource into a new namespace — would keep AA's stock + # default nav, inconsistently with its siblings. Namespace creation has + # its own notification; theme late arrivals through it. + ActiveSupport::Notifications.subscribe(::ActiveAdmin::Namespace::RegisterEvent) do |*args| + register.call(args.last[:active_admin_namespace]) + end + end +end diff --git a/package.json b/package.json index d53db33..0183ed2 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,6 @@ "src/**/*" ], "scripts": { - "prepublishOnly": "rm -rf src && cp -R app/assets/stylesheets/wigu/ src && cp app/assets/javascripts/wigu/theme_toggle.js src/" + "prepare": "node -e \"const fs=require('fs');fs.rmSync('src',{recursive:true,force:true});fs.cpSync('app/assets/stylesheets/wigu','src',{recursive:true});for(const f of fs.readdirSync('app/assets/javascripts/wigu'))fs.copyFileSync('app/assets/javascripts/wigu/'+f,'src/'+f)\"" } } diff --git a/test/capybara_boot.rb b/test/capybara_boot.rb new file mode 100644 index 0000000..90a0de6 --- /dev/null +++ b/test/capybara_boot.rb @@ -0,0 +1,35 @@ +# Shared Capybara + cuprite setup for the clock integration test and the +# screenshot task. Boots the dummy ActiveAdmin app (test/dummy) and points +# Capybara at it, driven by headless system Chrome via cuprite/ferrum. +ENV["RAILS_ENV"] ||= "test" + +require_relative "dummy/config/environment" +require "capybara" +require "capybara/cuprite" + +Capybara.app = Dummy::Application +Capybara.server = :puma, { Silent: true } +Capybara.default_max_wait_time = 5 + +Capybara.register_driver(:cuprite) do |app| + Capybara::Cuprite::Driver.new( + app, + window_size: [1200, 300], + headless: true, + # CI runners need --no-sandbox; their small /dev/shm makes Chrome crash on + # launch unless --disable-dev-shm-usage is set, which surfaces as ferrum's + # "Browser did not produce websocket url within N seconds". --disable-gpu is + # the usual headless companion. All harmless locally. Ferrum finds the system + # Chrome on its own (macOS app bundle or the ubuntu-latest preinstall). + browser_options: { + "no-sandbox" => nil, + "disable-dev-shm-usage" => nil, + "disable-gpu" => nil + }, + # First Chrome launch on a cold CI runner can exceed ferrum's 10s default. + process_timeout: 30 + ) +end + +Capybara.default_driver = :cuprite +Capybara.javascript_driver = :cuprite diff --git a/test/clock_test.rb b/test/clock_test.rb new file mode 100644 index 0000000..fa9bec0 --- /dev/null +++ b/test/clock_test.rb @@ -0,0 +1,53 @@ +# Integration test: the server clock renders as two ticking lines in the +# utility nav, and the logout item sits beside it. Proves the gem's JS +# (server_clock.js) + Ruby backend (install!) work end to end in a +# real browser against a real ActiveAdmin page. +require_relative "capybara_boot" +require "minitest/autorun" +require "capybara/minitest" + +class ClockTest < Minitest::Test + include Capybara::DSL + include Capybara::Minitest::Assertions + + def teardown + Capybara.reset_sessions! + end + + def test_clock_renders_two_ticking_lines_and_logout + visit "/admin" + + # 1. Two-line structure: li#servertime with a .clock-time and a .clock-date. + assert_selector "#utility_nav li#servertime", wait: 5 + assert_selector "#utility_nav li#servertime .clock-time", wait: 5 + assert_selector "#utility_nav li#servertime .clock-date", wait: 5 + + time_text = find("#utility_nav li#servertime .clock-time").text + date_text = find("#utility_nav li#servertime .clock-date").text + + # Shapes: "HH:MM:SS " over "YYYY DD". + assert_match(/\A\d{2}:\d{2}:\d{2}\s+\S+\z/, time_text, + "clock-time should read 'HH:MM:SS ', got #{time_text.inspect}") + assert_match(/\A\d{4}\s+[A-Z][a-z]{2}\s+\d{2}\z/, date_text, + "clock-date should read 'YYYY DD', got #{date_text.inspect}") + + # 2. Ticking: a retrying Capybara assertion, not a one-shot sample — a busy + # CI runner can delay the 1s interval past any fixed sleep, and tick() + # replaces the node each second, so re-querying until the text changes is + # the only race-free check. + assert_no_selector "#utility_nav li#servertime .clock-time", text: time_text, wait: 3 + + # 3. Logout lives in the utility nav too — specifically the THEME's item + # (a.wigu-logout), not AA's default, which would also appear if install! + # never ran. + assert_selector "#utility_nav li#logout > a.wigu-logout" + + # 4. The displayed clock is the SERVER's. Every assertion above would also + # pass against a client-side fallback (same selectors, same shapes, same + # ticking), so pin the data span the backend renders: the app's own zone + # and a fresh epoch. visible: :all — the JS hides it once mounted. + data = find("#utility_nav li#servertime [data-servertime-epoch]", visible: :all) + assert_equal Time.zone.tzinfo.identifier, data["data-servertime-zone"] + assert_in_delta Time.now.to_i, data["data-servertime-epoch"].to_i, 60 + end +end diff --git a/test/dummy/app/admin/dashboard.rb b/test/dummy/app/admin/dashboard.rb new file mode 100644 index 0000000..93beae6 --- /dev/null +++ b/test/dummy/app/admin/dashboard.rb @@ -0,0 +1,7 @@ +ActiveAdmin.register_page "Dashboard" do + menu priority: 1, label: proc { I18n.t("active_admin.dashboard") } + + content title: proc { I18n.t("active_admin.dashboard") } do + para "Welcome to the ActiveAdmin Theme Demo." + end +end diff --git a/test/dummy/app/assets/config/manifest.js b/test/dummy/app/assets/config/manifest.js new file mode 100644 index 0000000..694c926 --- /dev/null +++ b/test/dummy/app/assets/config/manifest.js @@ -0,0 +1,2 @@ +//= link active_admin.css +//= link active_admin.js diff --git a/test/dummy/app/assets/javascripts/active_admin.js b/test/dummy/app/assets/javascripts/active_admin.js new file mode 100644 index 0000000..6780be4 --- /dev/null +++ b/test/dummy/app/assets/javascripts/active_admin.js @@ -0,0 +1,3 @@ +//= require active_admin/base +//= require wigu/server_clock +//= require wigu/theme_toggle diff --git a/test/dummy/app/assets/stylesheets/active_admin.scss b/test/dummy/app/assets/stylesheets/active_admin.scss new file mode 100644 index 0000000..c0252fb --- /dev/null +++ b/test/dummy/app/assets/stylesheets/active_admin.scss @@ -0,0 +1,3 @@ +@import "active_admin/mixins"; +@import "active_admin/base"; +@import "wigu/active_admin_theme"; diff --git a/test/dummy/app/controllers/application_controller.rb b/test/dummy/app/controllers/application_controller.rb new file mode 100644 index 0000000..09705d1 --- /dev/null +++ b/test/dummy/app/controllers/application_controller.rb @@ -0,0 +1,2 @@ +class ApplicationController < ActionController::Base +end diff --git a/test/dummy/config/application.rb b/test/dummy/config/application.rb new file mode 100644 index 0000000..513502b --- /dev/null +++ b/test/dummy/config/application.rb @@ -0,0 +1,37 @@ +# Minimal ActiveAdmin host app. Its only jobs are to render an AA page with the +# theme active so the Capybara test can prove the server clock renders + ticks, +# and to be the source for the neutral README screenshot. Deliberately no DB +# schema: AA comments are off and there are no resources, so nothing queries. +require "rails" +require "active_record/railtie" +require "action_controller/railtie" +require "action_view/railtie" +require "sprockets/railtie" + +require "active_admin" +# The gem under test. Requiring it registers ActiveAdminTheme::Rails::Engine, +# whose initializer calls install! — that injects li#servertime (with the +# data-servertime instant) and the logout item into every namespace. +require "active_admin_theme" + +module Dummy + class Application < Rails::Application + # Booted by requiring this file directly (not from test/dummy as cwd), so + # pin the root or Rails looks for config/database.yml in the repo root. + config.root = File.expand_path("..", __dir__) + config.load_defaults 7.1 + config.eager_load = false + config.consider_all_requests_local = true + config.secret_key_base = "dummy-secret-key-base-for-tests-only" + config.hosts.clear + + config.logger = Logger.new($stdout) + config.log_level = :warn + + # Sprockets: compile on the fly, no digests — simplest thing that serves + # active_admin.css / active_admin.js in dev/test. + config.assets.compile = true + config.assets.debug = false + config.public_file_server.enabled = true + end +end diff --git a/test/dummy/config/database.yml b/test/dummy/config/database.yml new file mode 100644 index 0000000..3de036b --- /dev/null +++ b/test/dummy/config/database.yml @@ -0,0 +1,11 @@ +# In-memory SQLite for every env — there is no schema to persist. +default: &default + adapter: sqlite3 + database: ":memory:" + +development: + <<: *default +test: + <<: *default +production: + <<: *default diff --git a/test/dummy/config/environment.rb b/test/dummy/config/environment.rb new file mode 100644 index 0000000..332a7f4 --- /dev/null +++ b/test/dummy/config/environment.rb @@ -0,0 +1,7 @@ +require_relative "application" + +# In-memory SQLite: a connection exists if anything asks, but there is no schema +# to maintain (AA comments are disabled and there are no resources). +ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") + +Dummy::Application.initialize! diff --git a/test/dummy/config/initializers/active_admin.rb b/test/dummy/config/initializers/active_admin.rb new file mode 100644 index 0000000..63370d1 --- /dev/null +++ b/test/dummy/config/initializers/active_admin.rb @@ -0,0 +1,23 @@ +ActiveAdmin.setup do |config| + config.site_title = "ActiveAdmin Theme Demo" + + # No devise / no auth: the dummy exists only to render a themed page. + config.authentication_method = false + config.current_user_method = false + config.logout_link_path = "/admin" + + # No ActiveRecord-backed comments, so the app needs no schema. + config.comments = false +end + +# Auth is off (no login wall), but the theme's logout item is guarded by AA's +# `current_active_admin_user?`, which is false with current_user_method = false. +# Stub it true in the dummy so the theme-added li#logout actually renders — the +# whole point being to show the clock and logout sitting together in the nav. +Rails.application.config.to_prepare do + ActiveAdmin::BaseController.class_eval do + def current_active_admin_user? + true + end + end +end diff --git a/test/dummy/config/routes.rb b/test/dummy/config/routes.rb new file mode 100644 index 0000000..e6277d6 --- /dev/null +++ b/test/dummy/config/routes.rb @@ -0,0 +1,4 @@ +Rails.application.routes.draw do + ActiveAdmin.routes(self) + root to: redirect("/admin") +end diff --git a/test/screenshot.rb b/test/screenshot.rb new file mode 100644 index 0000000..ec5020b --- /dev/null +++ b/test/screenshot.rb @@ -0,0 +1,20 @@ +# Regenerates img/clock.png — the neutral README/PR screenshot of the two-line +# server clock sitting in the utility nav. Same dummy app and driver the test +# uses; crops to the AA header so the shot is just the top bar (site title +# "ActiveAdmin Theme Demo", dashboard menu, clock, logout) — no real content. +require_relative "capybara_boot" +require "capybara/dsl" + +include Capybara::DSL + +OUT = File.expand_path("../img/clock.png", __dir__) + +visit "/admin" +# Let the clock tick once so the two lines are populated by the JS. +find("#utility_nav li#servertime .clock-time", wait: 5) +sleep 1.1 + +page.driver.save_screenshot(OUT, selector: "#header") +Capybara.reset_sessions! + +puts "wrote #{OUT} (#{File.size(OUT)} bytes)"