#!/bin/bash set -uo pipefail # Secondary Chrome windows: which window is which, how a popup is placed, # and which one the dock offers to close. # # tests/test-classroom-chrome-windows.sh ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY' import importlib.machinery import importlib.util import json import os import sys import tempfile from pathlib import Path from classroom_chrome import ChromeRoles, chrome_windows root = Path(sys.argv[1]) path = root / "scripts/classroom-chrome-windows" loader = importlib.machinery.SourceFileLoader("classroom_chrome_windows", str(path)) spec = importlib.util.spec_from_loader(loader.name, loader) script = importlib.util.module_from_spec(spec) loader.exec_module(script) source = path.read_text() dock_source = (root / "scripts/classroom-dock").read_text() passed = failed = 0 def check(label, condition, detail=""): global passed, failed if condition: print(f" ok {label}") passed += 1 else: print(f" FAIL {label}") if detail: print(f" {detail}") failed += 1 def window(identifier, title, floating=False, hidden=False, focused=False): return { "id": identifier, "app_id": "google-chrome", "class": "", "instance": "", "pid": 4242, "name": title, "focused": focused, "floating": floating, "hidden": hidden, } print("1. creation order decides which window is the browser") roles = ChromeRoles() main = window(5, "Main browser - Google Chrome") popup = window(8, "about:blank - Google Chrome") change = roles.update([main]) check("the first Chrome window is the browser", change["primary"] == 5) check("it is not a popup", change["new_secondary"] == []) change = roles.update([main, popup]) check("a later Chrome window is a popup", change["new_secondary"] == [8]) check("the browser stays the browser", change["primary"] == 5) renamed_main = window(5, "Something else entirely") renamed_popup = window(8, "A different page title") change = roles.update([renamed_main, renamed_popup]) check("titles never change a role", change["primary"] == 5 and not change["new_secondary"]) check("a shared PID never confuses roles", len(chrome_windows([renamed_main, renamed_popup])) == 2) print() print("2. several popups at once") third = window(9, "Third window") change = roles.update([main, popup, third]) check("a third top-level is also a popup", change["new_secondary"] == [9]) change = roles.update([main, popup]) check("closing one leaves the other a popup", roles.secondary == [8]) check("the closed one is reported", change["removed"] == [9]) print() print("3. how a popup is placed") commands = [] script.command = lambda argument: commands.append(argument) script.SETTLE_SECONDS = 0 script.configure_secondary(8) check("floats exactly one container", commands[0] == "[con_id=8] floating enable") check("borderless, like the main browser", commands[1] == "[con_id=8] border none") check("about three quarters of the output", "width 76 ppt height 76 ppt" in commands[2], commands) check("centred", commands[3] == "[con_id=8] move position center") check("focused", commands[4] == "[con_id=8] focus") # Chrome resizes a popup after the fact, so the placement is repeated # once the size has settled or the window ends up off to one side. check("placed again after Chrome settles", commands[-1] == "[con_id=8] move position center" and "width 76 ppt" in commands[-2], commands) check("placed exactly twice, never on a loop", commands.count("[con_id=8] move position center") == 2, commands) check("never matches all Chrome windows at once", all("app_id" not in item for item in commands)) commands.clear() script.configure_secondary(8, arriving=False) check("restoring the role never moves the window", not any("move position" in item for item in commands), commands) check("restoring the role never raises it over a dialog", not any("focus" in item for item in commands), commands) check("it only puts the window back to floating and borderless", commands == ["[con_id=8] floating enable", "[con_id=8] border none"], commands) commands.clear() script.configure_primary(8) check("a promoted window goes back to tiled", commands[0] == "[con_id=8] floating disable") check("and borderless", commands[1] == "[con_id=8] border none") print() print("4. the dock's Close target survives losing focus") # The whole point: the pointer has to travel from the popup down to the # dock, and that moves focus. If the target followed focus the button # would vanish before anyone could reach it. active = script.ActiveSecondary() chrome = {5: window(5, "main", focused=True)} check("nothing to close with only the main browser", active.update([], chrome) is None) chrome = {5: window(5, "main"), 8: window(8, "popup", focused=True)} check("a focused popup becomes the target", active.update([8], chrome) == 8) chrome = {5: window(5, "main"), 8: window(8, "popup")} check("it stays the target when nothing is focused", active.update([8], chrome) == 8) chrome = {5: window(5, "main", focused=True), 8: window(8, "popup")} check("it stays when focus moves to the main browser", active.update([8], chrome) == 8) check("the main browser is never the target", active.target != 5) chrome = {5: window(5, "main"), 8: window(8, "popup"), 9: window(9, "dialog", focused=True)} check("a newer focused popup takes over", active.update([8, 9], chrome) == 9) chrome = {5: window(5, "main"), 8: window(8, "popup"), 9: window(9, "dialog")} check("and keeps it after focus moves to the dock", active.update([8, 9], chrome) == 9) chrome = {5: window(5, "main"), 8: window(8, "popup")} check("closing the dialog falls back to the render window", active.update([8], chrome) == 8) chrome = {5: window(5, "main")} check("closing the last popup clears it", active.update([], chrome) is None) fresh = script.ActiveSecondary() chrome = {5: window(5, "main", focused=True), 8: window(8, "popup")} check("an unfocused popup is still closeable", fresh.update([8], chrome) == 8) hidden = script.ActiveSecondary() chrome = {8: window(8, "one", hidden=True)} check("a minimised popup is not adopted", hidden.update([8], chrome) is None) print() print("4b. the print dialog stays in front") def win(identifier, title, focused=False, hidden=False): w = window(identifier, title, focused=focused, hidden=hidden) w["name"] = title return w check("the dialog is recognised by its exact title", script.is_print_dialog({"name": "Print"})) check("the render window is not", not script.is_print_dialog({"name": "Printable Document - Google Chrome"})) check("nothing else is", not script.is_print_dialog({"name": "Print Preview"})) check("a missing title is not", not script.is_print_dialog({})) commands.clear() # The render window has raised itself over the dialog. chrome = {5: win(5, "main"), 8: win(8, "Printable Document", focused=True), 9: win(9, "Print")} script.keep_print_in_front([8, 9], chrome) check("focus is taken back for the dialog", commands == ["[con_id=9] focus"], commands) commands.clear() chrome = {5: win(5, "main"), 8: win(8, "Printable Document"), 9: win(9, "Print", focused=True)} script.keep_print_in_front([8, 9], chrome) check("nothing happens while the dialog already has focus", commands == [], commands) commands.clear() chrome = {5: win(5, "main", focused=True), 8: win(8, "Printable Document"), 9: win(9, "Print")} script.keep_print_in_front([8, 9], chrome) check("focus is never stolen from the main browser", commands == [], commands) commands.clear() chrome = {5: win(5, "main"), 8: win(8, "Printable Document")} script.keep_print_in_front([8], chrome) check("nothing happens when there is no dialog", commands == [], commands) commands.clear() chrome = {8: win(8, "Printable Document", focused=True), 9: win(9, "Print", hidden=True)} script.keep_print_in_front([8, 9], chrome) check("a minimised dialog is left alone", commands == [], commands) # Both arriving together: whichever is configured last ends up in front, # so the dialog has to be last. order = sorted([8, 9], key=lambda i: script.is_print_dialog( {8: {"name": "Printable Document"}, 9: {"name": "Print"}}[i])) check("the dialog is configured after the render window", order == [8, 9], order) order = sorted([9, 8], key=lambda i: script.is_print_dialog( {8: {"name": "Printable Document"}, 9: {"name": "Print"}}[i])) check("even when the tree lists it first", order == [8, 9], order) print() print("5. the state the dock reads") work = Path(tempfile.mkdtemp()) state = work / "chrome-close-target" script.publish(9, state) check("publishes the container id", state.read_text().strip() == "9") check("published by rename, not left half written", not (work / "chrome-close-target.tmp").exists()) script.publish(None, state) check("empty when there is nothing to close", state.read_text().strip() == "") print() print("6. the dock owns the button, not the window") check("no layer-shell surface in the window helper", "GtkLayerShell" not in source) check("no second GTK process spawned for a button", "Gtk.main" not in source and "show_close_button" not in source) check("no geometry maths chasing a window", "output_rect" not in source and "button_margins" not in source) check("the dock has a close action", '"close"' in dock_source) check("the dock closes exactly one container", '[con_id={self.close_target}] kill' in dock_source) check("the dock never kills Chrome processes", "pkill" not in dock_source and "killall" not in dock_source) check("the dock watches rather than polls for it", "monitor_directory" in dock_source) print() print("7. driven by Sway events, not a poll") check("subscribes to window events", '"-t", "subscribe"' in source) check("ignores title churn", "title" not in script.INTERESTING and "new" in script.INTERESTING) check("reacts to focus and movement", {"focus", "move", "close"} <= set(script.INTERESTING)) check("no polling loop constant", "POLL_SECONDS" not in source) print() print("8. the session still starts it") config = (root / "config/sway/config").read_text() check("started with the session", "exec --no-startup-id /usr/local/bin/classroom-chrome-windows" in config) check("no broad Chrome floating rule", not any("floating enable" in line and "google-chrome" in line for line in config.splitlines())) print() print(f"passed: {passed} failed: {failed}") sys.exit(1 if failed else 0) PY