feat: highlight search matches and document the working UI
Core CI / core (ubuntu-latest) (push) Failing after 3m29s
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s
Core CI / desktop (ubuntu-latest) (push) Successful in 7m23s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s

This commit is contained in:
karl committed 2026-10-12 19:57:34 +02:00
1 parent b8400c4fbf
commit 79553ce921
26 files changed
+4124 -50

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
# Real-engine desktop screenshots
This independent software-test runner launches its own bundled headless Chromium
with CPU rendering/video-decode flags. It does not control the user's Chrome,
reuse personal profiles, or use Windows UI Automation. Screenshots render the
production frontend against the actual shared Rust/Google EmbeddingGemma 2 engine;
responses are neither replayed nor supplied by page evaluation.
Playwright 1.64.0 is pinned in the package lock. See the official
[browser installation](https://playwright.dev/docs/browsers) and
[screenshot API](https://playwright.dev/docs/screenshots). The browser binaries
stay under this tool's ignored node_modules directory.
## Prepare
Build the production frontend from apps/desktop with `npm ci && npm run build`.
Build/stage the CPU Google worker and verified media helpers as documented in
[BUILDING](../../docs/BUILDING.md). Provision the actual Google models first.
The backend uses a dedicated generated/verified fixture corpus, never a user
home/document directory. It indexes documents and two licensed real photos,
then warms its retained CPU worker before listening on 127.0.0.1:1431.
```powershell
$env:SEARCHI_FORCE_CPU = '1'
cargo run --manifest-path tools/desktop-capture/backend/Cargo.toml -- --root .searchi/readme-capture/files --db .searchi/readme-capture/index.sqlite --models .searchi/models/embeddinggemma2 --worker .searchi/runtime/cpu/Release/searchi-gemma-worker.exe --dist apps/desktop/dist --ffmpeg .searchi/media-runtime/bin/ffmpeg.exe --ffprobe .searchi/media-runtime/bin/ffprobe.exe
```
Keep that owned test process running while capturing. Its loopback API is a
fixture-only test adapter with origin/token checks, not a product HTTP server.
The production Tauri app keeps its native command/asset-protocol interface.
In another terminal, from the repository root:
```powershell
npm ci --prefix tools/desktop-capture
$env:PLAYWRIGHT_BROWSERS_PATH = '0'
npm run --prefix tools/desktop-capture install-browser
npm run --prefix tools/desktop-capture capture
```
The runner always uses the local browser cache and its own temporary profile.
CPU flags are `--disable-gpu --disable-accelerated-video-decode --disable-webgl`.
Only loopback fixture origin requests are permitted during capture. It waits for
actual backend readiness, measured hybrid responses, matching DOM results,
loaded fonts, image pixels, and genuine worker inference counters in debug mode.
## Artifacts and limits
Outputs under docs/screenshots are search-light.png, search-dark.png,
media-search.png and performance-details.png, plus capture-manifest.json. The
manifest contains actual fixture queries, counts/selection, measured response
metrics, device/model, viewport, PNG SHA256, runner/browser versions, Git base
commit and dirty-state flag. The base commit does not identify uncommitted
source exactly. The ephemeral API token is never saved in the manifest.
Captures are **browser-rendered production UI with the actual CPU engine**.
They omit native window chrome. They do not establish native WebView visual QA,
OS integration, playback codec parity, production relevance, GPU behavior or
other operating-system releases. The runner closes only its browser; stop only
the separately launched owned backend when finished.
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "searchi-desktop-capture"
version = "0.1.0"
edition = "2021"
publish = false
[workspace]
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tiny_http = "0.12"
mime_guess = "2"
uuid = { version = "1", features = ["v4"] }
sha2 = "0.10"
rusqlite = { version = "0.32", features = ["bundled"] }
lopdf = "0.45"
searchi-core = { path = "../../../crates/searchi-core" }
searchi-service = { path = "../../../crates/searchi-service" }
searchi-media = { path = "../../../crates/searchi-media" }
[dev-dependencies]
tempfile = "3"
+45
View File
@@ -0,0 +1,45 @@
# Real-engine screenshot backend
This independent software-test server serves the built desktop frontend and
connects its explicit `?capture=1` adapter to the actual shared Rust engine.
It is not the packaged desktop application or a production HTTP API.
The dedicated corpus contains five generated documents, including a genuine
two-page Supplier Agreement PDF, plus the licensed mountain-lake/night-pier
photos from `fixtures/media/fixture.json` (image03/image05). Photo checksums and
original license attribution are retained there. Tests verify actual PDF page-2
retrieval and unchanged-file reuse. No screenshots/results/vectors are replayed.
Build the frontend, then run this standalone Cargo crate with `--locked`:
```powershell
cargo run --manifest-path tools/desktop-capture/backend/Cargo.toml --locked -- `
--root .searchi/desktop-capture/corpus `
--db .searchi/desktop-capture/index.db `
--models .searchi/models/embeddinggemma2 `
--worker .searchi/runtime/cpu/Release/searchi-gemma-worker.exe `
--dist apps/desktop/dist `
--ffmpeg .searchi/media-runtime/bin/ffmpeg.exe `
--ffprobe .searchi/media-runtime/bin/ffprobe.exe
```
Indexing, real CPU model inference and prewarming finish before the server
listens on `http://127.0.0.1:1431/?capture=1`. It forces CPU mode and one model
thread. Runtime never downloads anything. Existing matching corpus files are
not rewritten; incompatible files or database roots are rejected.
`GET /capture-info` returns the explicit mode, readiness, a per-process token
and actual worker information. `POST /capture-api/{command}` requires the exact
loopback Host, same-origin Origin and `X-Searchi-Capture-Token`, with a 64 KiB
body cap. Only desktop/status/stats, measured lexical/hybrid search and fixed
indexed-photo preview commands exist. Indexing, file opening, settings and
arbitrary filesystem access are not browser commands. Static assets are
canonicalized beneath the supplied frontend build; media IDs are only `lake`
and `pier`. There are no CORS allowances or remote bindings.
The operator must supply an empty dedicated corpus root and database; the
server must not be pointed at user documents. This intentionally small fixture
demonstrates real behavior, not production-scale performance. Host/Origin/token,
path containment, unrelated-file rejection, PDF extraction and fixture reuse
have CPU tests. The browser runner records actual receipts and screenshot
measurements separately.
+519
View File
@@ -0,0 +1,519 @@
//! Independent screenshot test backend, not a production HTTP service.
use anyhow::{bail, ensure, Context, Result};
use clap::Parser;
use searchi_core::{SearchIndex, SearchOptions};
use searchi_service::{FfmpegTools, HybridSearch, MediaLimits};
use serde::Deserialize;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::{
collections::BTreeMap,
fs,
io::Read,
path::{Component, Path, PathBuf},
};
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
#[derive(Parser)]
struct Args {
/// Dedicated empty/generated corpus directory. Never a user document root.
#[arg(long)]
root: PathBuf,
#[arg(long)]
models: PathBuf,
#[arg(long)]
worker: PathBuf,
#[arg(long)]
db: PathBuf,
#[arg(long)]
dist: PathBuf,
#[arg(long)]
ffmpeg: PathBuf,
#[arg(long)]
ffprobe: PathBuf,
}
#[derive(Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct Filters {
folder: Option<PathBuf>,
extension: Option<String>,
min_size: Option<u64>,
max_size: Option<u64>,
min_duration_ms: Option<u32>,
max_duration_ms: Option<u32>,
modified_after_unix_ms: Option<i64>,
modified_before_unix_ms: Option<i64>,
}
impl Filters {
fn options(self, root: &Path) -> Result<SearchOptions> {
if let Some(folder) = &self.folder {
ensure!(
folder.canonicalize()?.starts_with(root),
"Capture folder filter must stay within the fixture root"
);
}
Ok(SearchOptions {
folder: self.folder,
extension: self.extension,
min_size: self.min_size,
max_size: self.max_size,
min_duration_ms: self.min_duration_ms,
max_duration_ms: self.max_duration_ms,
modified_after_unix_ms: self.modified_after_unix_ms,
modified_before_unix_ms: self.modified_before_unix_ms,
})
}
}
struct App {
root: PathBuf,
db: PathBuf,
dist: PathBuf,
models: PathBuf,
token: String,
lexical: SearchIndex,
hybrid: HybridSearch,
media: BTreeMap<String, PathBuf>,
}
fn main() -> Result<()> {
let args = Args::parse();
std::env::set_var("SEARCHI_FORCE_CPU", "1");
let root = prepare_fixture(&args.root)?;
if let Some(parent) = args.db.parent() {
fs::create_dir_all(parent)?;
}
let mut lexical = SearchIndex::open(&args.db)?;
validate_database(&args.db, &root)?;
lexical.index_folder(&root)?;
lexical.prewarm()?;
let tools = FfmpegTools::new(args.ffmpeg, args.ffprobe)?;
let mut hybrid = HybridSearch::open(&args.db, &args.models, &args.worker, 1)?;
let status = HybridSearch::embedding_status(&args.db)?;
if status.stale || status.generation.is_none() {
let report = hybrid.index_embeddings_with_media(&tools, &MediaLimits::default())?;
ensure!(
report.media_failures == 0,
"Capture media indexing failed: {:?}",
report.failures
);
println!(
"Indexed real fixture: {} passages, {} vectors, {} images",
report.passages, report.chunks, report.media_files
);
}
hybrid.warmup()?;
let dist = args.dist.canonicalize()?;
ensure!(
dist.join("index.html").is_file(),
"Build the frontend before capture"
);
let media = BTreeMap::from([
("lake".into(), root.join("Mountain Lake.jpg")),
("pier".into(), root.join("Night Pier.jpg")),
]);
let app = App {
root,
db: args.db,
dist,
models: args.models,
token: uuid::Uuid::new_v4().to_string(),
lexical,
hybrid,
media,
};
let server = Server::http(("127.0.0.1", 1431)).map_err(|e| anyhow::anyhow!(e.to_string()))?;
println!("READY http://127.0.0.1:1431/?capture=1 (real CPU engine, local fixture only)");
serve(server, app)
}
fn valid_host(host: &str) -> bool {
matches!(host, "127.0.0.1:1431" | "localhost:1431")
}
fn permitted(headers: &[(String, String)], post: bool, token: &str) -> bool {
let get = |key: &str| {
headers
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case(key))
.map(|(_, value)| value.as_str())
};
let Some(host) = get("host") else {
return false;
};
if !valid_host(host) {
return false;
}
if let Some(origin) = get("origin") {
if origin != format!("http://{host}") {
return false;
}
}
!post
|| (get("origin") == Some(format!("http://{host}").as_str())
&& get("x-searchi-capture-token") == Some(token))
}
fn serve(server: Server, mut app: App) -> Result<()> {
for mut request in server.incoming_requests() {
let headers = request
.headers()
.iter()
.map(|h| (h.field.as_str().to_string(), h.value.as_str().to_owned()))
.collect::<Vec<_>>();
let post = request.method() == &Method::Post;
if !permitted(&headers, post, &app.token) {
reply(
request,
403,
"application/json",
br#"{"message":"Capture request denied"}"#.to_vec(),
)?;
continue;
}
let path = request.url().split('?').next().unwrap_or("").to_string();
let outcome: Result<(u16, String, Vec<u8>)> = (|| {
if request.method() == &Method::Get && path == "/capture-info" {
return Ok((
200,
"application/json".into(),
serde_json::to_vec(
&json!({"mode":"readme-capture","token":app.token,"ready":true,"worker_info":app.hybrid.worker_info(),"rust_build_profile":if cfg!(debug_assertions) {"debug"} else {"release"}}),
)?,
));
}
if post {
let command = path
.strip_prefix("/capture-api/")
.context("Unknown capture route")?;
ensure!(
request.body_length().is_none_or(|n| n <= 65536),
"Capture request too large"
);
let mut body = Vec::new();
request.as_reader().take(65537).read_to_end(&mut body)?;
ensure!(body.len() <= 65536, "Capture request too large");
let args: Value = serde_json::from_slice(&body)?;
return Ok((
200,
"application/json".into(),
serde_json::to_vec(&app.command(command, args)?)?,
));
}
ensure!(request.method() == &Method::Get, "Unsupported method");
if let Some(id) = path.strip_prefix("/capture-media/") {
let file = app.media.get(id).context("Unknown fixture media ID")?;
ensure!(
app.lexical.contains_path(&file.to_string_lossy())?,
"Media no longer indexed"
);
return Ok((200, "image/jpeg".into(), bounded_read(file)?));
}
let file = static_path(&app.dist, &path)?;
let mime = mime_guess::from_path(&file)
.first_or_octet_stream()
.to_string();
Ok((200, mime, bounded_read(&file)?))
})();
match outcome {
Ok((status, mime, body)) => reply(request, status, &mime, body)?,
Err(error) => reply(
request,
400,
"application/json",
serde_json::to_vec(&json!({"message":format!("{error:#}")}))?,
)?,
}
}
Ok(())
}
fn reply(request: Request, status: u16, mime: &str, body: Vec<u8>) -> Result<()> {
let response = Response::from_data(body)
.with_status_code(StatusCode(status))
.with_header(
Header::from_bytes("Content-Type", mime)
.map_err(|_| anyhow::anyhow!("Invalid content type"))?,
)
.with_header(Header::from_bytes("Cache-Control", "no-store").unwrap())
.with_header(Header::from_bytes("X-Content-Type-Options", "nosniff").unwrap());
request.respond(response)?;
Ok(())
}
fn bounded_read(path: &Path) -> Result<Vec<u8>> {
ensure!(path.is_file(), "Capture resource must be a regular file");
let mut bytes = Vec::new();
fs::File::open(path)?
.take(20 * 1024 * 1024 + 1)
.read_to_end(&mut bytes)?;
ensure!(
bytes.len() <= 20 * 1024 * 1024,
"Capture resource exceeds20MiB"
);
Ok(bytes)
}
fn static_path(dist: &Path, url: &str) -> Result<PathBuf> {
ensure!(
!url.contains('%') && !url.contains('\\'),
"Encoded or Windows paths are not capture routes"
);
let relative = Path::new(url.trim_start_matches('/'));
ensure!(
relative
.components()
.all(|c| matches!(c, Component::Normal(_))),
"Unsafe static resource path"
);
let target = if relative.as_os_str().is_empty() {
dist.join("index.html")
} else {
dist.join(relative)
};
let target = target.canonicalize()?;
ensure!(
target.starts_with(dist),
"Static resource escapes build directory"
);
Ok(target)
}
impl App {
fn command(&mut self, name: &str, args: Value) -> Result<Value> {
match name {
"desktop_status" => Ok(
json!({"settings":{"roots":[self.root],"cpu_threads":1},"indexing":false,"shortcut_status":"Ctrl K · reproducible CPU capture"}),
),
"stats" => Ok(serde_json::to_value(self.lexical.stats()?)?),
"semantic_status" => {
let status = HybridSearch::embedding_status(&self.db)?;
Ok(
json!({"model":"Google EmbeddingGemma 2","ready":true,"model_directory":self.models,"worker_ready":true,"downloading":false,"embedding":false,"device_policy":"cpu","error":null,"downloaded_bytes":864676480u64,"total_bytes":864676480u64,"generation":status.generation,"chunks":status.chunks,"stale":status.stale,"pending":status.pending,"warming":false,"worker_info":self.hybrid.worker_info(),"media_tools_ready":true,"media_setup_error":null,"media_failures":status.failures}),
)
}
"search_measured" | "hybrid_search_measured" => {
let query = args["query"].as_str().context("Query required")?;
ensure!(query.len() <= 4096, "Query too large");
let filters: Filters = if args["filters"].is_null() {
Filters::default()
} else {
serde_json::from_value(args["filters"].clone())?
};
let options = filters.options(&self.root)?;
let debug = args["debugPerformance"].as_bool().unwrap_or(false);
if name == "search_measured" {
Ok(serde_json::to_value(
self.lexical
.search_with_options_measured(query, 50, &options, debug)?,
)?)
} else {
Ok(serde_json::to_value(
self.hybrid.search_measured(query, 50, &options, debug)?,
)?)
}
}
"prepare_media_preview" => {
let path = args["path"].as_str().context("Media path required")?;
let (id, _) = self
.media
.iter()
.find(|(_, file)| file.to_string_lossy() == path)
.context("Only known indexed fixture photos may be previewed")?;
ensure!(self.lexical.contains_path(path)?, "Photo no longer indexed");
Ok(
json!({"path":format!("/capture-media/{id}"),"kind":"image","mime":"image/jpeg"}),
)
}
_ => bail!("This capture harness supports read-only fixture commands"),
}
}
}
fn validate_database(db: &Path, root: &Path) -> Result<()> {
let connection = rusqlite::Connection::open(db)?;
let mut query = connection.prepare("SELECT path FROM files")?;
for path in query.query_map([], |row| row.get::<_, String>(0))? {
let path = PathBuf::from(path?);
ensure!(
path.parent() == Some(root),
"Capture database contains files outside dedicated fixture root"
);
ensure!(
fixture_names().contains(&path.file_name().unwrap().to_string_lossy().as_ref()),
"Capture database contains unrelated files"
);
}
Ok(())
}
fn fixture_names() -> Vec<&'static str> {
vec![
"Supplier Agreement.pdf",
"Vendor Onboarding.md",
"Q3 Procurement.md",
"Travel Notes.md",
"Garden Notes.md",
"Mountain Lake.jpg",
"Night Pier.jpg",
]
}
fn prepare_fixture(root: &Path) -> Result<PathBuf> {
fs::create_dir_all(root)?;
let root = root.canonicalize()?;
for entry in fs::read_dir(&root)? {
let entry = entry?;
ensure!(
entry.file_type()?.is_file()
&& fixture_names().contains(&entry.file_name().to_string_lossy().as_ref()),
"Use an empty dedicated capture root, not user files"
);
}
let texts=[("Vendor Onboarding.md","# Vendor onboarding\nCollect supplier tax details, banking information and insurance documents. The purchasing team completes compliance review before signing a new partnership."),("Q3 Procurement.md","# Q3 procurement review\nWe will review supplier delivery times, packaging costs and the annual purchasing budget. Present the comparison of existing vendors at Thursday's planning meeting."),("Travel Notes.md","# Travel plans\nTake the train to Berlin for the museum weekend. Reserve a room near the station and pack a raincoat."),("Garden Notes.md","# Garden notes\nTomatoes need sunlight and regular watering. Plant herbs in pots on the balcony and add compost in spring.")];
for (name, text) in texts {
write_known(&root.join(name), text.as_bytes())?;
}
supplier_pdf(&root.join("Supplier Agreement.pdf"))?;
let repo = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../..")
.canonicalize()?;
let manifest: Value =
serde_json::from_slice(&fs::read(repo.join("fixtures/media/fixture.json"))?)?;
for (id, name) in [
("image03", "Mountain Lake.jpg"),
("image05", "Night Pier.jpg"),
] {
let asset = manifest["assets"]
.as_array()
.context("Missing licensed assets")?
.iter()
.find(|a| a["id"] == id)
.context("Missing fixed licensed photo")?;
let source = repo
.join("fixtures/media")
.join(asset["path"].as_str().unwrap());
let bytes = bounded_read(&source)?;
ensure!(
asset["sha256"].as_str() == Some(format!("{:x}", Sha256::digest(&bytes)).as_str()),
"Licensed photo checksum changed"
);
write_known(&root.join(name), &bytes)?;
}
Ok(root)
}
fn write_known(path: &Path, bytes: &[u8]) -> Result<()> {
if path.exists() {
ensure!(
fs::read(path)? == bytes,
"Capture fixture file differs; use a new dedicated root"
);
} else {
fs::write(path, bytes)?;
}
Ok(())
}
fn supplier_pdf(path: &Path) -> Result<()> {
use lopdf::{
content::{Content, Operation},
dictionary, Document, Object, Stream,
};
let mut document = Document::with_version("1.5");
let pages_id = document.new_object_id();
let font=document.add_object(dictionary!{"Type"=>"Font","Subtype"=>"Type1","BaseFont"=>"Helvetica","Encoding"=>"WinAnsiEncoding"});
let resources = document.add_object(dictionary! {"Font"=>dictionary!{"F1"=>font}});
let mut pages = Vec::new();
for lines in [
vec![
"Supplier Agreement",
"The purchasing team and supplier enter a partnership.",
"Orders and delivery schedules are confirmed in writing.",
],
vec![
"Termination and cancellation",
"The supplier partnership may be ended with sixty days",
"written notice. Send the cancellation letter to the vendor.",
"Return unused materials and settle outstanding invoices.",
],
]
.iter()
{
let mut operations = vec![
Operation::new("BT", vec![]),
Operation::new("Tf", vec![Object::Name(b"F1".to_vec()), 12.into()]),
Operation::new("Td", vec![50.into(), 740.into()]),
];
for line in lines {
operations.push(Operation::new(
"Tj",
vec![Object::string_literal(format!("{line} "))],
));
operations.push(Operation::new("Td", vec![0.into(), (-20).into()]));
}
operations.push(Operation::new("ET", vec![]));
let stream = document.add_object(Stream::new(
dictionary! {},
Content { operations }.encode()?,
));
let page =
document.add_object(dictionary! {"Type"=>"Page","Parent"=>pages_id,"Contents"=>stream});
pages.push(Object::Reference(page));
}
document.objects.insert(pages_id,Object::Dictionary(dictionary!{"Type"=>"Pages","Kids"=>pages,"Count"=>2,"Resources"=>resources,"MediaBox"=>vec![0.into(),0.into(),595.into(),842.into()]}));
let catalog = document.add_object(dictionary! {"Type"=>"Catalog","Pages"=>pages_id});
document.trailer.set("Root", catalog);
document.compress();
let mut bytes = Vec::new();
document.save_to(&mut bytes)?;
write_known(path, &bytes)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fixture_reuses_unchanged_files_and_real_pdf_page_two() -> Result<()> {
let temporary = tempfile::tempdir()?;
let root = prepare_fixture(&temporary.path().join("corpus"))?;
let db = temporary.path().join("index.db");
let mut index = SearchIndex::open(&db)?;
validate_database(&db, &root)?;
let first = index.index_folder(&root)?;
assert_eq!(first.discovered, 7);
let hits = index.search("sixty days", 10)?;
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].filename, "Supplier Agreement.pdf");
assert_eq!(hits[0].source.as_ref().unwrap().number, 2);
prepare_fixture(&root)?;
let reused = index.index_folder(&root)?;
assert_eq!(reused.unchanged, 7);
assert_eq!(reused.indexed, 0);
let foreign = temporary.path().join("foreign");
fs::create_dir(&foreign)?;
fs::write(foreign.join("secret.txt"), "private")?;
index.index_folder(&foreign)?;
assert!(validate_database(&db, &root).is_err());
Ok(())
}
#[test]
fn origin_host_and_token_all_required_for_post() {
let headers = vec![
("Host".into(), "127.0.0.1:1431".into()),
("Origin".into(), "http://127.0.0.1:1431".into()),
("X-Searchi-Capture-Token".into(), "secret".into()),
];
assert!(permitted(&headers, true, "secret"));
assert!(!permitted(&headers, true, "wrong"));
assert!(!permitted(&headers[..1], true, "secret"));
let mut attacker = headers.clone();
attacker[1].1 = "https://attacker.example".into();
assert!(!permitted(&attacker, true, "secret"));
attacker[0].1 = "attacker.example:1431".into();
assert!(!permitted(&attacker, false, "secret"));
}
#[test]
fn static_routes_reject_traversal_and_unrelated_root_files() -> Result<()> {
let root = tempfile::tempdir()?;
fs::write(root.path().join("index.html"), "real frontend")?;
let canonical = root.path().canonicalize()?;
assert!(static_path(&canonical, "/").is_ok());
for path in ["/../secret", "/%2e%2e/secret", "/C:\\secret"] {
assert!(static_path(&canonical, path).is_err());
}
fs::write(root.path().join("user-secret.txt"), "private")?;
assert!(prepare_fixture(root.path()).is_err());
Ok(())
}
}
+398
View File
@@ -0,0 +1,398 @@
/** Capture the production frontend against a live, real local Rust/Gemma fixture. */
import { mkdir, writeFile } from "node:fs/promises";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { execFileSync } from "node:child_process";
process.env.PLAYWRIGHT_BROWSERS_PATH = "0";
const { chromium } = await import("playwright");
const repository = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const target = new URL(
process.env.SEARCHI_CAPTURE_URL ?? "http://127.0.0.1:1431/?capture=1",
);
assert(
target.protocol === "http:" &&
["127.0.0.1", "localhost"].includes(target.hostname),
"Capture must use an owned loopback server",
);
assert(
target.searchParams.get("capture") === "1",
"The explicit capture adapter must be enabled",
);
const out = resolve(
repository,
process.env.SEARCHI_CAPTURE_OUTPUT ?? "docs/screenshots",
);
const infoResponse = await fetch(new URL("/capture-info", target), {
signal: AbortSignal.timeout(15000),
});
assert(infoResponse.ok, "Real fixture backend is not ready");
const info = await infoResponse.json();
assert(
info.mode === "readme-capture" && info.ready === true,
"Requires an initialized real-engine capture backend",
);
await mkdir(out, { recursive: true });
const browser = await chromium.launch({
headless: true,
args: [
"--disable-gpu",
"--disable-accelerated-video-decode",
"--disable-webgl",
],
});
const failures = [];
const evidence = [];
const measured = [];
let latestMeasured;
const source = {
base_commit: execFileSync("git", ["rev-parse", "HEAD"], {
cwd: repository,
encoding: "utf8",
}).trim(),
working_tree_dirty:
execFileSync("git", ["status", "--porcelain"], {
cwd: repository,
encoding: "utf8",
}).trim().length > 0,
};
try {
const context = await browser.newContext({
viewport: { width: 1440, height: 1000 },
deviceScaleFactor: 1,
colorScheme: "light",
reducedMotion: "reduce",
});
await context.route("**/*", (route) => {
const url = new URL(route.request().url());
return url.origin === target.origin ||
["data:", "blob:"].includes(url.protocol)
? route.continue()
: route.abort();
});
const page = await context.newPage();
page.on("pageerror", (error) => failures.push(String(error)));
await page.goto(target.href, { waitUntil: "networkidle", timeout: 30000 });
await page.waitForFunction(
() =>
document
.querySelector("#semantic-status")
?.textContent?.includes("Semantic search ready"),
{},
{ timeout: 120000 },
);
await page.evaluate(async () => {
await document.fonts.ready;
});
assert(
await page
.locator(".brand-symbol")
.evaluate((image) => image.complete && image.naturalWidth > 0),
"Brand asset failed to load",
);
async function query(text, debug = false) {
const awaited = page.waitForResponse(
(response) => {
if (
new URL(response.url()).pathname !==
"/capture-api/hybrid_search_measured"
)
return false;
const args = response.request().postDataJSON();
return args?.query === text && Boolean(args.debugPerformance) === debug;
},
{ timeout: 60000 },
);
await page.locator("#query").fill(text);
const response = await awaited;
assert(response.ok(), "Real measured engine query failed");
const body = await response.json();
assert(
Array.isArray(body.results) && body.metrics,
"Measured backend response missing actual results/metrics",
);
latestMeasured = {
query: text,
mode: debug ? "debug" : "basic",
result_count: body.results.length,
metrics: body.metrics,
first_filename: body.results[0]?.filename,
first_source: body.results[0]?.source,
};
measured.push(latestMeasured);
await page.waitForFunction(
(expected) => {
const summary =
document.querySelector("#result-summary")?.textContent ?? "";
return (
document.querySelector("#query")?.value === expected.query &&
summary.includes("KEYWORD + MEANING") &&
document.querySelector("#results .result strong")?.textContent ===
expected.filename &&
document.querySelectorAll("#results .result").length ===
expected.count
);
},
{
query: text,
filename: latestMeasured.first_filename,
count: latestMeasured.result_count,
},
{ timeout: 60000 },
);
assert(
(await page.locator("#results .result").count()) > 0,
`Real query returned no matches: ${text}`,
);
await page.locator("#results .result").first().click();
await page.waitForFunction(
() => !document.querySelector("#preview")?.hidden,
);
if (text === "cancel the supplier partnership") {
assert.equal(
latestMeasured.first_filename,
"Supplier Agreement.pdf",
"Contract query must retrieve the known PDF fixture",
);
assert.equal(
latestMeasured.first_source?.kind,
"page",
"PDF match must retain actual page source",
);
assert.equal(
latestMeasured.first_source?.number,
2,
"Termination clause must be on actual page2",
);
const passage = await page.locator("#preview-text").innerText();
assert.match(
passage,
/sixty|\b60\b/i,
"Actual PDF preview is missing notice period",
);
assert.match(
passage,
/terminat/i,
"Actual PDF preview is missing termination section",
);
const literalWords = ["supplier", "partnership"];
const rowMarks = await page
.locator("#results .result")
.first()
.locator('.snippet mark[data-match="literal"]')
.allTextContents();
const passageMarks = await page
.locator('#preview-text mark[data-match="literal"]')
.allTextContents();
for (const word of literalWords) {
assert(
rowMarks.some((mark) => mark.toLowerCase() === word),
`Result snippet must highlight actual query word ${word}`,
);
assert(
passageMarks.some((mark) => mark.toLowerCase() === word),
`PDF passage must highlight actual query word ${word}`,
);
}
const why = page.locator("#match-reason");
const disclosure = why.locator("xpath=ancestor::details[1]");
assert.equal(
await disclosure.count(),
1,
"Match reason must live in a compact disclosure",
);
assert.equal(
await disclosure.evaluate((element) => element.open),
false,
"Why details should stay collapsed by default",
);
await disclosure.locator(":scope > summary").click();
await why.waitFor({ state: "visible" });
assert.match(
await page.locator("#match-reason").innerText(),
/page\s*2/i,
"Match explanation must use actual PDF page2",
);
await disclosure.locator(":scope > summary").click();
assert.equal(
await disclosure.evaluate((element) => element.open),
false,
"Normal screenshot must preserve collapsed why details",
);
latestMeasured.ui_checks = {
literal_query_marks: true,
actual_page_explanation: true,
why_disclosure_collapsed_by_default: true,
};
}
if (text === "mountains reflected in calm water") {
assert.equal(
latestMeasured.first_filename,
"Mountain Lake.jpg",
"Visual query must retrieve the known licensed lake",
);
assert.equal(
await page.locator("#preview-text mark").count(),
0,
"Meaning-only photo result must not invent word-level alignment",
);
latestMeasured.ui_checks = { meaning_only_image_has_no_text_marks: true };
}
await page.evaluate(
() =>
new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
),
);
}
async function screenshot(name, queryText) {
assert.deepEqual(failures, [], "Frontend page errors occurred");
const path = resolve(out, `${name}.png`);
await page.screenshot({ path, animations: "disabled", fullPage: false });
const selected = await page.locator("#preview-title").innerText();
assert.equal(
selected,
latestMeasured.first_filename,
"Selected DOM result is not the actual first hybrid result",
);
evidence.push({
file: `${name}.png`,
sha256: createHash("sha256")
.update(await readFile(path))
.digest("hex"),
query: queryText,
mode: latestMeasured.mode,
result_count: latestMeasured.result_count,
selected_filename: selected,
first_source_kind: latestMeasured.first_source?.kind,
first_source_number: latestMeasured.first_source?.number,
theme: await page.evaluate(() =>
matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
),
summary: await page.locator("#result-summary").innerText(),
state_assertions: {
semantic_ready: true,
hybrid_final: true,
image_visible: name === "media-search",
actual_worker_inference:
latestMeasured.mode === "debug" &&
latestMeasured.metrics.trace.some(
(span) => span.counters.worker_inference_us > 0,
),
...latestMeasured.ui_checks,
},
});
}
const documentQuery = "cancel the supplier partnership";
await query(documentQuery);
await screenshot("search-light", documentQuery);
await page.emulateMedia({ colorScheme: "dark" });
await screenshot("search-dark", documentQuery);
await page.emulateMedia({ colorScheme: "light" });
const imageQuery = "mountains reflected in calm water";
await query(imageQuery);
await page.waitForFunction(
() => {
const image = document.querySelector("#media-preview img");
return (
image instanceof HTMLImageElement &&
image.complete &&
image.naturalWidth > 0
);
},
{},
{ timeout: 60000 },
);
await screenshot("media-search", imageQuery);
await page.locator("#settings summary").click();
await page.locator("#performance-toggle").check();
await page.locator("#settings summary").click();
await query(documentQuery, true);
await page.locator("#performance-panel").waitFor({ state: "visible" });
await page.locator("#performance-panel summary").click();
await page.waitForFunction(
() =>
document
.querySelector("#performance-detail")
?.textContent?.includes("hybrid") ||
document
.querySelector("#performance-detail")
?.textContent?.includes("Hybrid"),
);
assert(
latestMeasured.metrics.trace?.some(
(span) => span.counters.worker_inference_us > 0,
),
"Detailed trace did not record real worker inference",
);
await page
.locator("#performance-detail .performance-span")
.filter({ hasText: "worker inference" })
.first()
.scrollIntoViewIfNeeded();
await screenshot("performance-details", documentQuery);
const semanticResponse = await context.request.post(
new URL("/capture-api/semantic_status", target).href,
{
headers: { "X-Searchi-Capture-Token": info.token, Origin: target.origin },
data: {},
},
);
assert(semanticResponse.ok(), "Actual semantic status unavailable");
const semantic = await semanticResponse.json();
assert.equal(
semantic.worker_info?.model,
"google/embeddinggemma-2",
"Capture requires the actual Google model",
);
assert.equal(
semantic.worker_info?.threads,
1,
"Capture inference requires one CPU thread",
);
assert.equal(
semantic.worker_info?.device,
"cpu",
"Capture worker is not CPU",
);
await writeFile(
resolve(out, "capture-manifest.json"),
JSON.stringify(
{
kind: "browser-rendered-production-ui-real-cpu-engine",
source,
viewport: { width: 1440, height: 1000 },
runner: { playwright: "1.64.0", chromium: browser.version() },
backend: {
mode: info.mode,
model: semantic.worker_info.model,
device: semantic.worker_info.device,
runtime: semantic.worker_info.runtime,
threads: semantic.worker_info.threads,
dimensions: semantic.worker_info.dimensions,
ready: info.ready,
rust_build_profile: info.rust_build_profile,
},
cpu_flags: [
"--disable-gpu",
"--disable-accelerated-video-decode",
"--disable-webgl",
],
native_window_chrome: false,
native_visual_qa_complete: false,
measured_queries: measured,
screenshots: evidence,
},
null,
2,
) + "\n",
);
console.log(
`Captured ${evidence.length} real-engine UI screenshots in ${out}`,
);
await context.close();
} finally {
await browser.close();
}
+42
View File
@@ -0,0 +1,42 @@
{
"name": "searchi-desktop-capture",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "searchi-desktop-capture",
"version": "0.1.0",
"dependencies": {
"playwright": "1.64.0"
}
},
"node_modules/playwright": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "searchi-desktop-capture",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"capture": "node capture.mjs",
"install-browser": "playwright install chromium --only-shell"
},
"dependencies": {
"playwright": "1.64.0"
}
}