- hypr/apps.lua - hypr/autostart.lua - hypr/envs.lua - hypr/hyprland.lua - hypr/hyprsunset.conf - hypr/input.lua - hypr/looknfeel.lua - hypr/omasettings.lua - hypr/xdph.conf - omarchy/branding/about.txt - omarchy/branding/screensaver.txt - omarchy/extensions/omarchy-menu.jsonc - omarchy/hooks/battery-low.d/play-warning-sound.sample - omarchy/hooks/font-set.d/show-font-notification.sample - omarchy/hooks/post-boot.d/weather.sample - omarchy/hooks/post-update.d/install-voxtype.hook - omarchy/hooks/post-update.d/setup-agent.hook - omarchy/hooks/post-update.d/setup-fingerprint.hook - omarchy/hooks/post-update.d/show-update-notification.sample - omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample - omarchy/hooks/theme-set.d/show-theme-notification.sample - omarchy/shell.json - omarchy/shell.toml - omarchy/theme.name - omarchy/themes/azure-glow/README.md - omarchy/themes/azure-glow/alacritty.toml - omarchy/themes/azure-glow/btop.theme - omarchy/themes/azure-glow/hyprland.conf - omarchy/themes/azure-glow/hyprlock.conf - omarchy/themes/azure-glow/icons.theme - … 269 more
330 lines
17 KiB
JavaScript
330 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
||
// Regression tests for the context-aware suggestion matcher in BitwardenModel.js.
|
||
//
|
||
// The matcher is heuristic and works from window titles alone, so it is easy to
|
||
// regress in both directions: too strict and the right login stops appearing,
|
||
// too loose and every .com item is suggested on every site. Run with:
|
||
//
|
||
// node tests/context-match.test.js
|
||
|
||
const fs = require("fs")
|
||
const path = require("path")
|
||
|
||
const src = fs.readFileSync(path.join(__dirname, "..", "BitwardenModel.js"), "utf8")
|
||
const Model = {}
|
||
new Function("exports", src.replace(/^\.pragma library\s*$/m, "") + `
|
||
exports.findContextualMatches = findContextualMatches
|
||
exports.cleanWindowContext = cleanWindowContext
|
||
exports.parseHost = parseHost
|
||
exports.parseAssociations = parseAssociations
|
||
exports.serializeAssociations = serializeAssociations
|
||
exports.recordAssociation = recordAssociation
|
||
exports.forgetAssociation = forgetAssociation
|
||
exports.isAssociated = isAssociated
|
||
exports.emptyAssociations = emptyAssociations
|
||
exports.MAX_TITLE_CHARS = MAX_TITLE_CHARS
|
||
exports.MAX_ASSOC_BYTES = MAX_ASSOC_BYTES
|
||
`)(Model)
|
||
|
||
const items = [
|
||
{ id: "1", name: "GitHub", uris: ["https://github.com"] },
|
||
{ id: "2", name: "Amazon", uris: ["https://www.amazon.com"] },
|
||
{ id: "3", name: "Home Assistant",uris: ["https://homeassistant.local:8123"] },
|
||
{ id: "4", name: "Google", uris: ["https://accounts.google.com"] },
|
||
{ id: "5", name: "Reddit", uris: ["https://reddit.com"] },
|
||
{ id: "6", name: "Proton Mail", uris: ["https://account.proton.me"] },
|
||
{ id: "7", name: "Cloudflare", uris: ["https://dash.cloudflare.com"] },
|
||
{ id: "8", name: "My Bank", uris: ["https://www.chase.com"] },
|
||
{ id: "9", name: "Netflix", uris: ["https://www.netflix.com"] },
|
||
{ id: "10", name: "Jellyfin", uris: ["http://192.168.1.50:8096"] },
|
||
{ id: "11", name: "GitHub (work)", uris: ["https://github.com"] },
|
||
{ id: "12", name: "BBC iPlayer", uris: ["https://www.bbc.co.uk"] },
|
||
{ id: "13", name: "Discord", uris: ["https://discord.com"] },
|
||
{ id: "14", name: "Slack", uris: ["https://acme.slack.com"] },
|
||
{ id: "15", name: "Spotify", uris: ["https://open.spotify.com"] },
|
||
{ id: "16", name: "Nextcloud", uris: ["https://cloud.example.org"] },
|
||
{ id: "17", name: "Router Admin", uris: ["http://192.168.1.1"] },
|
||
{ id: "18", name: "AWS Console", uris: ["https://console.aws.amazon.com"] },
|
||
]
|
||
|
||
// [window class, window title, expected suggestion names]
|
||
const cases = [
|
||
// Site name lives in the title but the domain does not -- the common case.
|
||
["chromium", "Settings – Home Assistant - Chromium", ["Home Assistant"]],
|
||
["chromium", "Reddit - Dive into anything - Chromium", ["Reddit"]],
|
||
["chromium", "Netflix - Chromium", ["Netflix"]],
|
||
["chromium", "Proton Mail - Chromium", ["Proton Mail"]],
|
||
["chromium", "Cloudflare Dashboard - Chromium", ["Cloudflare"]],
|
||
["chromium", "Chase Online - Credit Cards, Mortgages, Auto - Chromium", ["My Bank"]],
|
||
["chromium", "Jellyfin - Chromium", ["Jellyfin"]],
|
||
["chromium", "Files - Nextcloud - Chromium", ["Nextcloud"]],
|
||
["chromium", "Acme Corp Slack - Chromium", ["Slack"]],
|
||
["chromium", "Spotify – Web Player - Chromium", ["Spotify"]],
|
||
["chromium", "AWS Management Console - Chromium", ["AWS Console"]],
|
||
["firefox", "BBC iPlayer - Home — Mozilla Firefox", ["BBC iPlayer"]],
|
||
["chromium", "Discord | #general | My Server - Chromium", ["Discord"]],
|
||
|
||
// Sign-in prefixes, unread counters and browser branding are noise.
|
||
["firefox", "Sign in to GitHub · GitHub — Mozilla Firefox", ["GitHub", "GitHub (work)"]],
|
||
["chromium", "(3) Inbox (1,204) - me@gmail.com - Gmail - Chromium", ["Google"]],
|
||
|
||
// Brand alias: the title never says "google".
|
||
["chromium", "Gmail - Chromium", ["Google"]],
|
||
["chromium", "Untitled document - Google Docs - Chromium", ["Google"]],
|
||
|
||
// A domain in the title must not drag in every item sharing its TLD.
|
||
["chromium", "Amazon.com. Spend less. Smile more. - Chromium", ["Amazon", "AWS Console"]],
|
||
|
||
// Nothing identifiable: suggest nothing rather than guess.
|
||
["chromium", "New Tab - Chromium", []],
|
||
["chromium", "Sign in - Chromium", []],
|
||
["chromium", "How to fix config.json v1.2 errors - Stack Overflow - Chromium", []],
|
||
|
||
// Terminals: local shells describe a machine, not a credential.
|
||
["foot", "workstation: notes", []],
|
||
// ...and a remote host must not match a different domain sharing a label.
|
||
["foot", "ssh user@git.example.com", []],
|
||
|
||
// Native desktop apps match on window class.
|
||
["discord", "Discord | #general", ["Discord"]],
|
||
["spotify", "Spotify Premium", ["Spotify"]],
|
||
]
|
||
|
||
// A large vault of .com sites: a single site must not suggest all of them.
|
||
const floodItems = ["github","amazon","google","reddit","proton","cloudflare","chase",
|
||
"netflix","discord","slack","spotify","dropbox","twitch","ebay","paypal","stripe",
|
||
"linode","digitalocean","namecheap","fastmail","zoom","notion","figma","linear",
|
||
"vercel","heroku","atlassian","gitlab","bitbucket","sentry","datadog","okta",
|
||
"auth0","twilio","sendgrid","mailgun","shopify","squarespace","wordpress","medium"]
|
||
.map((b, i) => ({ id: String(i), name: b, uris: ["https://www." + b + ".com"] }))
|
||
|
||
let pass = 0
|
||
const failures = []
|
||
|
||
function check(label, ok, detail) {
|
||
if (ok) { pass++ } else { failures.push(label + "\n " + detail) }
|
||
}
|
||
|
||
const sshLike = { id: "ssh-public", name: "GitHub deploy key", typeCode: 5,
|
||
uris: ["https://github.com"], publicKey: "ssh-ed25519 AAAA" }
|
||
check("context suggestions exclude SSH public records",
|
||
Model.findContextualMatches(items.concat([sshLike]),
|
||
{ class: "chromium", title: "GitHub - Chromium", mapped: true }).matches.every(m => m.id !== "ssh-public"),
|
||
"SSH key leaked into contextual suggestions")
|
||
|
||
for (const [cls, title, expected] of cases) {
|
||
const got = Model.findContextualMatches(items, { class: cls, title, mapped: true })
|
||
.matches.map(m => m.name).sort()
|
||
check(`[${cls}] ${title}`,
|
||
JSON.stringify(got) === JSON.stringify(expected.slice().sort()),
|
||
`expected [${expected}] but got [${got}]`)
|
||
}
|
||
|
||
for (const [title, max] of [["Amazon.com. Spend less. Smile more. - Chromium", 1],
|
||
["Some Random Blog Post About Nothing - Chromium", 0]]) {
|
||
const got = Model.findContextualMatches(floodItems, { class: "chromium", title, mapped: true }).matches
|
||
check(`flood: ${title}`, got.length <= max,
|
||
`expected at most ${max} suggestion(s) from a 40-item vault, got ${got.length}: [${got.map(g => g.name)}]`)
|
||
}
|
||
|
||
// Hostname parsing feeds every domain comparison.
|
||
for (const [host, root, base] of [
|
||
["github.com", "github", "github.com"],
|
||
["dash.cloudflare.com", "cloudflare", "cloudflare.com"],
|
||
["www.bbc.co.uk", "bbc", "bbc.co.uk"],
|
||
["homeassistant.local", "homeassistant", "homeassistant.local"],
|
||
["console.aws.amazon.com", "amazon", "amazon.com"],
|
||
]) {
|
||
const p = Model.parseHost(host)
|
||
check(`parseHost(${host})`, p && p.rootName === root && p.baseDomain === base,
|
||
`expected root=${root} base=${base}, got root=${p && p.rootName} base=${p && p.baseDomain}`)
|
||
}
|
||
|
||
// The most recently focused non-shell client wins in a `hyprctl clients` list.
|
||
const clients = [
|
||
{ class: "quickshell", title: "shell", focusHistoryID: 0, mapped: true },
|
||
{ class: "chromium", title: "Netflix - Chromium", focusHistoryID: 1, mapped: true },
|
||
{ class: "foot", title: "workstation: notes", focusHistoryID: 2, mapped: true },
|
||
]
|
||
check("clients list picks most recent non-shell window",
|
||
Model.findContextualMatches(items, clients).matches.map(m => m.name).join() === "Netflix",
|
||
`got [${Model.findContextualMatches(items, clients).matches.map(m => m.name)}]`)
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Learned associations
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// The case that no title heuristic can solve: an authentik portal on
|
||
// auth.example.xyz titled "Home - authentik". The title and the stored URL
|
||
// share no word at all.
|
||
// Named so that neither the name nor the URL shares a word with the page title.
|
||
const authentikItem = { id: "auth-1", name: "Personal SSO", uris: ["https://auth.example.xyz"] }
|
||
const withAuthentik = items.concat([authentikItem])
|
||
const authentikWindow = { class: "chromium", title: "Home - authentik - Chromium", mapped: true }
|
||
|
||
let assoc = Model.emptyAssociations()
|
||
|
||
check("authentik: unmatched before learning",
|
||
Model.findContextualMatches(withAuthentik, authentikWindow, assoc).matches.length === 0,
|
||
`got [${Model.findContextualMatches(withAuthentik, authentikWindow, assoc).matches.map(m => m.name)}]`)
|
||
|
||
// Pick the credential once while that window is active.
|
||
const ctx = Model.cleanWindowContext(authentikWindow)
|
||
assoc = Model.recordAssociation(assoc, ctx, authentikItem.id, "2026-08-20T00:00:00Z")
|
||
|
||
check("authentik: suggested after one pick",
|
||
Model.findContextualMatches(withAuthentik, authentikWindow, assoc).matches.map(m => m.id).join() === "auth-1",
|
||
`got [${Model.findContextualMatches(withAuthentik, authentikWindow, assoc).matches.map(m => m.name)}]`)
|
||
|
||
// Learning generalises across pages of the same site, which share the word.
|
||
check("authentik: generalises to another page of the same site",
|
||
Model.findContextualMatches(withAuthentik,
|
||
{ class: "chromium", title: "Applications - authentik - Chromium", mapped: true }, assoc)
|
||
.matches.map(m => m.id).join() === "auth-1",
|
||
"expected the learned item on a sibling page")
|
||
|
||
check("authentik: does not leak to unrelated sites",
|
||
Model.findContextualMatches(withAuthentik,
|
||
{ class: "chromium", title: "Netflix - Chromium", mapped: true }, assoc)
|
||
.matches.map(m => m.name).join() === "Netflix",
|
||
"a learned key must not fire on an unrelated title")
|
||
|
||
check("isAssociated reports the learned pair", Model.isAssociated(assoc, ctx, "auth-1"), "expected true")
|
||
|
||
// Last pick wins, so a key learned from the wrong page corrects itself.
|
||
const retargeted = Model.recordAssociation(assoc, ctx, "9", "2026-08-21T00:00:00Z")
|
||
check("re-picking retargets the key",
|
||
Model.findContextualMatches(withAuthentik, authentikWindow, retargeted).matches.map(m => m.id).join() === "9",
|
||
"expected the newly picked item to win")
|
||
|
||
// Explicit unlearn.
|
||
const forgotten = Model.forgetAssociation(assoc, ctx, "auth-1")
|
||
check("forgetting removes the suggestion",
|
||
Model.findContextualMatches(withAuthentik, authentikWindow, forgotten).matches.length === 0,
|
||
"expected no suggestions after forgetting")
|
||
|
||
// A learned item outranks a heuristic match on the same window.
|
||
let netflixAssoc = Model.recordAssociation(Model.emptyAssociations(),
|
||
Model.cleanWindowContext({ class: "chromium", title: "Netflix - Chromium", mapped: true }), "11")
|
||
check("learned item is ranked ahead of a heuristic match",
|
||
Model.findContextualMatches(items, { class: "chromium", title: "Netflix - Chromium", mapped: true }, netflixAssoc)
|
||
.matches[0].id === "11",
|
||
"expected the learned item first")
|
||
|
||
// Round-tripping through the on-disk format must preserve behaviour.
|
||
const roundTripped = Model.parseAssociations(Model.serializeAssociations(assoc))
|
||
check("associations survive a save/load round trip",
|
||
Model.findContextualMatches(withAuthentik, authentikWindow, roundTripped).matches.map(m => m.id).join() === "auth-1",
|
||
"expected the learned item after reload")
|
||
|
||
check("corrupt association file degrades to empty",
|
||
Model.parseAssociations("{{not json").keys && Object.keys(Model.parseAssociations("{{not json").keys).length === 0,
|
||
"expected an empty store")
|
||
|
||
const hostileAssociations = Model.parseAssociations(
|
||
'{"version":1,"keys":{"__proto__":{"polluted":true},"arbitrary":{"itemId":"x"},'
|
||
+ '"word:valid":{"itemId":"auth-1","weight":1,"count":2,"updated":"2026-08-24T00:00:00Z"}}}')
|
||
const copiedHostile = Model.recordAssociation(hostileAssociations, ctx, "auth-1", "2026-12-31T00:00:00Z")
|
||
check("association parsing drops keys outside the domain/app/word schema",
|
||
!Object.prototype.hasOwnProperty.call(hostileAssociations.keys, "__proto__")
|
||
&& !Object.prototype.hasOwnProperty.call(hostileAssociations.keys, "arbitrary")
|
||
&& Object.prototype.hasOwnProperty.call(hostileAssociations.keys, "word:valid"),
|
||
Object.keys(hostileAssociations.keys).join(","))
|
||
check("hostile association keys cannot become the prototype of a copied store",
|
||
copiedHostile.keys.polluted === undefined, JSON.stringify(copiedHostile.keys))
|
||
check("association entries without a bounded string item id are dropped",
|
||
Object.keys(Model.parseAssociations(
|
||
'{"version":1,"keys":{"word:bad":{"itemId":{}},"word:good":{"itemId":"x"}}}').keys).join()
|
||
=== "word:good",
|
||
"invalid item id survived")
|
||
check("unknown association schema versions fail closed",
|
||
Object.keys(Model.parseAssociations(
|
||
'{"version":999,"keys":{"word:old":{"itemId":"x"}}}').keys).length === 0,
|
||
"unknown schema was accepted")
|
||
|
||
// Suggestions must still work with no association store at all.
|
||
check("undefined associations are safe",
|
||
Model.findContextualMatches(items, { class: "chromium", title: "Netflix - Chromium", mapped: true })
|
||
.matches.map(m => m.name).join() === "Netflix",
|
||
"expected heuristics to work without a store")
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// A window title is written by the page, not by the user
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// Everything below is about one input: document.title, chosen by whatever the
|
||
// browser is pointed at, arriving here through hyprctl. It is read on every
|
||
// panel open, on the GUI thread, with the whole vault to compare it against.
|
||
|
||
// The title reaches the matcher clipped, so the per-item work the matcher does
|
||
// over it cannot be scaled up by the page.
|
||
const longTitle = "verylongword".repeat(6000)
|
||
const clippedCtx = Model.cleanWindowContext({ class: "chromium", title: longTitle, mapped: true })
|
||
check("a page-chosen title is clipped before matching",
|
||
clippedCtx.matchText.length <= Model.MAX_TITLE_CHARS
|
||
&& clippedCtx.rawTitle.length <= Model.MAX_TITLE_CHARS,
|
||
`matchText=${clippedCtx.matchText.length} rawTitle=${clippedCtx.rawTitle.length}`)
|
||
|
||
// The host scanner used to be a single unanchored regex, and a long run of
|
||
// letters with no dot in it drove it into quadratic backtracking: ~2.5s of
|
||
// frozen shell for a 63 kB title, growing with the square of the length.
|
||
// hyprctl hands over as much as a megabyte, so this is the honest size.
|
||
const hostileTitle = "a".repeat(100000) + " - Chromium"
|
||
const hostileWindow = { class: "chromium", title: hostileTitle, mapped: true }
|
||
const bigVault = []
|
||
for (let i = 0; i < 2000; i++) {
|
||
bigVault.push({ id: `big-${i}`, name: `Account ${i}`, uris: [`https://site${i}example.com`] })
|
||
}
|
||
const started = Date.now()
|
||
Model.findContextualMatches(bigVault, hostileWindow, Model.emptyAssociations())
|
||
const elapsed = Date.now() - started
|
||
check("a hostile window title does not stall the matcher", elapsed < 2000,
|
||
`matching a 100 kB title against 2000 items took ${elapsed}ms`)
|
||
|
||
// Splitting replaced the regex, so prove it still reads the same hosts out.
|
||
for (const [text, expected] of [
|
||
["Files - Nextcloud - cloud.example.org", "example.org"],
|
||
["https://sub.example.co.uk/path", "example.co.uk"],
|
||
["see .example.com now", "example.com"],
|
||
["a..b.example.com", "example.com"],
|
||
["config.json is not a host", null],
|
||
["version 1.2.3.4 released", null],
|
||
["nothing here at all", null],
|
||
]) {
|
||
const got = Model.cleanWindowContext({ class: "chromium", title: text, mapped: true })
|
||
const base = got && got.detectedDomain ? got.detectedDomain.baseDomain : null
|
||
check(`host detection in ${JSON.stringify(text)}`, base === expected,
|
||
`expected ${expected}, got ${base}`)
|
||
}
|
||
|
||
// Every word of a title becomes a stored key, and the store is read back
|
||
// through a byte cap that turns an oversized file into no file at all. The
|
||
// write side has to stay under that cap on its own.
|
||
let grown = Model.emptyAssociations()
|
||
for (let p = 0; p < 300; p++) {
|
||
const words = []
|
||
for (let w = 0; w < 80; w++) words.push(`tok${p}x${w}zz`)
|
||
grown = Model.recordAssociation(grown,
|
||
Model.cleanWindowContext({ class: "chromium", title: words.join(" "), mapped: true }),
|
||
"auth-1", `2026-08-${String((p % 28) + 1).padStart(2, "0")}T00:00:00Z`)
|
||
}
|
||
const grownBytes = Model.serializeAssociations(grown).length
|
||
check("the association store stays inside the cap it is read back through",
|
||
grownBytes < Model.MAX_ASSOC_BYTES,
|
||
`store grew to ${grownBytes} bytes against a ${Model.MAX_ASSOC_BYTES} byte read cap`)
|
||
|
||
// Trimming must not cost the most recent lesson.
|
||
const recentCtx = Model.cleanWindowContext(authentikWindow)
|
||
const stillLearned = Model.recordAssociation(grown, recentCtx, "auth-1", "2026-12-31T00:00:00Z")
|
||
check("the newest lesson survives trimming",
|
||
Model.findContextualMatches(withAuthentik, authentikWindow, stillLearned).matches.map(m => m.id).join() === "auth-1",
|
||
"expected the just-learned item to still be suggested")
|
||
|
||
console.log(`${pass} passed, ${failures.length} failed`)
|
||
if (failures.length) {
|
||
console.error("\nFAILURES:\n " + failures.join("\n "))
|
||
process.exit(1)
|
||
}
|