dev/e2e: disposable server for app e2e / agent UI testing

`dev/e2e/e2e.sh up` runs an isolated ImageApi (port 8090, own build dir,
db, thumbnails, HLS) next to a real local server, serving a copy of a
synthetic fixture library. Every fixture image has its path, date and
scenario burned into the pixels; manifest.json records expected dates,
GPS, cameras, tags and counts. `reset` restores the baseline.

- LOCAL_BIND_URL makes the hardcoded localhost:8088 listener
  configurable (default unchanged; empty disables it) so a second
  instance can start.
- create_user bin: the server has no registration route; idempotent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-09-29 13:02:49 -04:00
1 parent da3901c564
commit f00a309a24
7 files changed
+719 -6

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
//! Create a login user. The server has no registration route, so this is
//! how a fresh database (e.g. the `dev/e2e` harness) gets its first account.
//!
//! Idempotent: an existing username is left untouched and reported, so the
//! harness can re-run it on every reset. Runs pending migrations first so it
//! works against a database the server has never opened.
//!
//! Usage: `create_user <username> <password>` (reads `DATABASE_URL` from
//! `.env` / the environment like the server does).
use anyhow::{Result, bail};
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
use image_api::connect;
use image_api::database::{SqliteUserDao, UserDao};
const MIGRATIONS: EmbeddedMigrations = embed_migrations!();
fn main() -> Result<()> {
dotenv::dotenv().ok();
let args: Vec<String> = std::env::args().skip(1).collect();
let [username, password] = args.as_slice() else {
bail!("usage: create_user <username> <password>");
};
connect()
.run_pending_migrations(MIGRATIONS)
.map_err(|e| anyhow::anyhow!("failed to run migrations: {e}"))?;
let mut dao = SqliteUserDao::new();
if dao.user_exists(username) {
println!("user '{username}' already exists; leaving it unchanged");
return Ok(());
}
match dao.create_user(username, password) {
Some(_) => println!("created user '{username}'"),
None => bail!("failed to create user '{username}'"),
}
Ok(())
}
+15 -5
View File
@@ -285,7 +285,7 @@ fn main() -> std::io::Result<()> {
// Spawn the on-disk reel-cache sweeper (bounds pre-gen + on-demand reels).
reels::spawn_reel_cache_sweeper(app_state.clone()).await;
HttpServer::new(move || {
let server = HttpServer::new(move || {
let user_dao = SqliteUserDao::new();
let favorites_dao = SqliteFavoriteDao::new();
let tag_dao = SqliteTagDao::default();
@@ -471,10 +471,20 @@ fn main() -> std::io::Result<()> {
.app_data::<Data<InsightGenerator>>(Data::new(app_data.insight_generator.clone()))
.wrap(prometheus.clone())
})
.bind(dotenv::var("BIND_URL").unwrap())?
.bind("localhost:8088")?
.run()
.await
.bind(dotenv::var("BIND_URL").unwrap())?;
// Secondary loopback listener. Defaults to localhost:8088; set
// LOCAL_BIND_URL to move it, or to an empty string to skip it —
// required to run a second instance (e.g. the dev/e2e harness)
// alongside one that already owns 8088.
let local_bind = env::var("LOCAL_BIND_URL").unwrap_or_else(|_| "localhost:8088".into());
let server = if local_bind.trim().is_empty() {
server
} else {
server.bind(local_bind.trim())?
};
server.run().await
})
}