Add active profile local data export
This commit is contained in:
@@ -10,6 +10,8 @@ use crate::services::prd_live_sites::{
|
||||
assert_prd_reference_urls_are_covered,
|
||||
};
|
||||
|
||||
#[cfg(feature = "live-site-smoke")]
|
||||
const LIVE_SITE_RENDER_ATTEMPTS: usize = 3;
|
||||
#[cfg(feature = "live-site-smoke")]
|
||||
const LIVE_SITE_WIDTH: u32 = 934;
|
||||
#[cfg(feature = "live-site-smoke")]
|
||||
@@ -147,23 +149,7 @@ fn prd_reference_live_site_cases_cover_prd_urls() -> Result<(), Box<dyn Error>>
|
||||
fn assert_live_sites_render(cases: &[LiveSiteCase]) -> Result<(), Box<dyn Error>> {
|
||||
let client = ServoSidecarClient::new()?;
|
||||
for case in cases {
|
||||
let request = SidecarSnapshotRequest::new(
|
||||
UrlText::parse(case.url)?,
|
||||
ProfileId::new(),
|
||||
LIVE_SITE_WIDTH,
|
||||
LIVE_SITE_HEIGHT,
|
||||
);
|
||||
let snapshot = client.snapshot(request)?;
|
||||
|
||||
assert_eq!(snapshot.width(), LIVE_SITE_WIDTH, "{}", case.url);
|
||||
assert_eq!(snapshot.height(), LIVE_SITE_HEIGHT, "{}", case.url);
|
||||
assert_render_state_is_open(snapshot.render_state(), case.url);
|
||||
assert_loaded_url_contains(&snapshot, case.url)?;
|
||||
assert_title_contains(&snapshot, case.title_fragment)?;
|
||||
assert!(snapshot.non_white_pixel_count > 0, "{}", case.url);
|
||||
assert!(snapshot.content_pixel_count >= MINIMUM_CONTENT_PIXELS, "{}", case.url);
|
||||
assert!(snapshot.sample_hash > 0, "{}", case.url);
|
||||
|
||||
let snapshot = render_live_site_snapshot(&client, case)?;
|
||||
let rgba_bytes = snapshot.into_rgba_bytes();
|
||||
assert_eq!(
|
||||
rgba_bytes.len(),
|
||||
@@ -176,26 +162,81 @@ fn assert_live_sites_render(cases: &[LiveSiteCase]) -> Result<(), Box<dyn Error>
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-site-smoke")]
|
||||
fn assert_render_state_is_open(state: &str, url: &str) {
|
||||
assert!(matches!(state, "complete" | "loading"), "{url} state: {state}");
|
||||
fn render_live_site_snapshot(
|
||||
client: &ServoSidecarClient,
|
||||
case: &LiveSiteCase,
|
||||
) -> Result<SidecarSnapshot, Box<dyn Error>> {
|
||||
let mut last_error = String::new();
|
||||
|
||||
for attempt in 0..LIVE_SITE_RENDER_ATTEMPTS {
|
||||
let request = SidecarSnapshotRequest::new(
|
||||
UrlText::parse(case.url)?,
|
||||
ProfileId::new(),
|
||||
LIVE_SITE_WIDTH,
|
||||
LIVE_SITE_HEIGHT,
|
||||
);
|
||||
|
||||
match client.snapshot(request) {
|
||||
Ok(snapshot) => match validate_live_site_snapshot(&snapshot, case) {
|
||||
Ok(()) => return Ok(snapshot),
|
||||
Err(error) => last_error = error,
|
||||
},
|
||||
Err(error) => last_error = error.to_string(),
|
||||
}
|
||||
|
||||
if attempt + 1 < LIVE_SITE_RENDER_ATTEMPTS {
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.into())
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-site-smoke")]
|
||||
fn assert_loaded_url_contains(
|
||||
fn validate_live_site_snapshot(
|
||||
snapshot: &SidecarSnapshot,
|
||||
fragment: &str,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
case: &LiveSiteCase,
|
||||
) -> Result<(), String> {
|
||||
require(
|
||||
snapshot.width() == LIVE_SITE_WIDTH,
|
||||
format!("{} width: {}", case.url, snapshot.width()),
|
||||
)?;
|
||||
require(
|
||||
snapshot.height() == LIVE_SITE_HEIGHT,
|
||||
format!("{} height: {}", case.url, snapshot.height()),
|
||||
)?;
|
||||
require_render_state_is_open(snapshot.render_state(), case.url)?;
|
||||
require_loaded_url_contains(snapshot, case.url)?;
|
||||
require_title_contains(snapshot, case.title_fragment)?;
|
||||
require(snapshot.non_white_pixel_count > 0, case.url.to_string())?;
|
||||
require(
|
||||
snapshot.content_pixel_count >= MINIMUM_CONTENT_PIXELS,
|
||||
format!("{} content pixels: {}", case.url, snapshot.content_pixel_count),
|
||||
)?;
|
||||
require(snapshot.sample_hash > 0, case.url.to_string())
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-site-smoke")]
|
||||
fn require_render_state_is_open(state: &str, url: &str) -> Result<(), String> {
|
||||
require(matches!(state, "complete" | "loading"), format!("{url} state: {state}"))
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-site-smoke")]
|
||||
fn require_loaded_url_contains(snapshot: &SidecarSnapshot, fragment: &str) -> Result<(), String> {
|
||||
let loaded_url =
|
||||
snapshot.loaded_url().ok_or_else(|| format!("missing loaded URL for {fragment}"))?;
|
||||
assert!(loaded_url.contains(fragment), "loaded_url: {loaded_url}");
|
||||
Ok(())
|
||||
require(loaded_url.contains(fragment), format!("loaded_url: {loaded_url}"))
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-site-smoke")]
|
||||
fn assert_title_contains(snapshot: &SidecarSnapshot, fragment: &str) -> Result<(), Box<dyn Error>> {
|
||||
fn require_title_contains(snapshot: &SidecarSnapshot, fragment: &str) -> Result<(), String> {
|
||||
let title = snapshot.title().ok_or_else(|| format!("missing title containing {fragment}"))?;
|
||||
assert!(title.contains(fragment), "title: {title}");
|
||||
Ok(())
|
||||
require(title.contains(fragment), format!("title: {title}"))
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-site-smoke")]
|
||||
fn require(condition: bool, message: String) -> Result<(), String> {
|
||||
if condition { Ok(()) } else { Err(message) }
|
||||
}
|
||||
|
||||
fn report_with_state(state: &str, profile_id: &ProfileId) -> SidecarReport {
|
||||
|
||||
@@ -23,6 +23,11 @@ enum BookmarkFileCommand {
|
||||
Import,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum LocalDataFileCommand {
|
||||
Export,
|
||||
}
|
||||
|
||||
impl ElyShell {
|
||||
pub(super) fn handle_shell_command_intent(
|
||||
&mut self,
|
||||
@@ -60,6 +65,11 @@ impl ElyShell {
|
||||
Some(BookmarkFileCommand::Import) => self.choose_bookmark_import(window, cx),
|
||||
None => {}
|
||||
}
|
||||
|
||||
match local_data_file_command(command) {
|
||||
Some(LocalDataFileCommand::Export) => self.export_local_data(window, cx),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,11 +120,22 @@ fn bookmark_file_command(command: &str) -> Option<BookmarkFileCommand> {
|
||||
}
|
||||
}
|
||||
|
||||
fn local_data_file_command(command: &str) -> Option<LocalDataFileCommand> {
|
||||
match command.trim().to_ascii_lowercase().as_str() {
|
||||
"export-local-data"
|
||||
| "export local data"
|
||||
| "export-privacy-data"
|
||||
| "export privacy data" => Some(LocalDataFileCommand::Export),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
BookmarkFileCommand, ShortcutFileCommand, SpaceFileCommand, bookmark_file_command,
|
||||
install_plugin_from_file_command, shortcut_file_command, space_file_command,
|
||||
BookmarkFileCommand, LocalDataFileCommand, ShortcutFileCommand, SpaceFileCommand,
|
||||
bookmark_file_command, install_plugin_from_file_command, local_data_file_command,
|
||||
shortcut_file_command, space_file_command,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -160,4 +181,16 @@ mod tests {
|
||||
assert_eq!(bookmark_file_command("export-bookmarks"), Some(BookmarkFileCommand::Export));
|
||||
assert_eq!(bookmark_file_command("import bookmarks"), Some(BookmarkFileCommand::Import));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_data_file_command_matches_export_aliases() {
|
||||
assert_eq!(
|
||||
local_data_file_command("export-local-data"),
|
||||
Some(LocalDataFileCommand::Export)
|
||||
);
|
||||
assert_eq!(
|
||||
local_data_file_command("export privacy data"),
|
||||
Some(LocalDataFileCommand::Export)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
use ely_browser_core::{BrowserSnapshot, LocalDataInventory};
|
||||
use ely_design_system::colors;
|
||||
use gpui::{AnyElement, IntoElement, ParentElement, Styled, div, rgb};
|
||||
use gpui_component::{IconName, StyledExt};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, rgb};
|
||||
use gpui_component::{
|
||||
IconName, Sizable, StyledExt,
|
||||
button::{Button, ButtonVariants},
|
||||
};
|
||||
|
||||
pub(super) fn render_local_data_inventory(snapshot: &BrowserSnapshot) -> AnyElement {
|
||||
use super::ElyShell;
|
||||
|
||||
pub(super) fn render_local_data_inventory(
|
||||
snapshot: &BrowserSnapshot,
|
||||
notice: Option<&str>,
|
||||
error: Option<&str>,
|
||||
cx: &mut Context<ElyShell>,
|
||||
) -> AnyElement {
|
||||
let inventory = snapshot.local_data_inventory;
|
||||
|
||||
div()
|
||||
@@ -16,7 +27,8 @@ pub(super) fn render_local_data_inventory(snapshot: &BrowserSnapshot) -> AnyElem
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(render_inventory_header(snapshot, inventory))
|
||||
.child(render_inventory_header(snapshot, inventory, cx))
|
||||
.when_some(file_message(notice, error), |this, message| this.child(message))
|
||||
.child(render_inventory_rows(inventory))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -24,6 +36,7 @@ pub(super) fn render_local_data_inventory(snapshot: &BrowserSnapshot) -> AnyElem
|
||||
fn render_inventory_header(
|
||||
snapshot: &BrowserSnapshot,
|
||||
inventory: LocalDataInventory,
|
||||
cx: &mut Context<ElyShell>,
|
||||
) -> AnyElement {
|
||||
div()
|
||||
.flex()
|
||||
@@ -33,6 +46,7 @@ fn render_inventory_header(
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
@@ -61,20 +75,57 @@ fn render_inventory_header(
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.border_color(rgb(colors::HAIRLINE))
|
||||
.bg(rgb(colors::CANVAS))
|
||||
.px_3()
|
||||
.py_2()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(rgb(colors::INK))
|
||||
.child(format!("{} items", inventory.total_items())),
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.border_color(rgb(colors::HAIRLINE))
|
||||
.bg(rgb(colors::CANVAS))
|
||||
.px_3()
|
||||
.py_2()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(rgb(colors::INK))
|
||||
.child(format!("{} items", inventory.total_items())),
|
||||
)
|
||||
.child(
|
||||
Button::new("export-local-data")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.icon(IconName::File)
|
||||
.label("Export")
|
||||
.tooltip("Export Local Data")
|
||||
.on_click(cx.listener(|shell, _, window, cx| {
|
||||
shell.export_local_data(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn file_message(notice: Option<&str>, error: Option<&str>) -> Option<AnyElement> {
|
||||
notice
|
||||
.map(|message| render_file_message(message, colors::SUCCESS))
|
||||
.or_else(|| error.map(|message| render_file_message(message, colors::ERROR)))
|
||||
}
|
||||
|
||||
fn render_file_message(message: &str, color: u32) -> AnyElement {
|
||||
div()
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.border_color(rgb(color))
|
||||
.px_3()
|
||||
.py_2()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(rgb(color))
|
||||
.child(message.to_string())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_inventory_rows(inventory: LocalDataInventory) -> AnyElement {
|
||||
div()
|
||||
.flex()
|
||||
|
||||
@@ -32,7 +32,12 @@ impl ElyShell {
|
||||
.when(snapshot.active_profile_history_entry_count > 0, |this| {
|
||||
this.child(render_history_clear_controls(confirming_clear, cx))
|
||||
})
|
||||
.child(render_local_data_inventory(snapshot))
|
||||
.child(render_local_data_inventory(
|
||||
snapshot,
|
||||
self.local_data_file_notice.as_deref(),
|
||||
self.local_data_file_error.as_deref(),
|
||||
cx,
|
||||
))
|
||||
.child(render_privacy_settings_rows(snapshot, cx)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use directories::UserDirs;
|
||||
use ely_browser_core::ELYDATA_FILE_EXTENSION;
|
||||
use gpui::{Context, Window};
|
||||
|
||||
use super::{ElyShell, ShellState};
|
||||
|
||||
const PRIVACY_SECURITY_URL: &str = "ely://settings/privacy-security";
|
||||
|
||||
impl ElyShell {
|
||||
pub(super) fn export_local_data(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.ensure_privacy_security_surface(window, cx);
|
||||
self.clear_local_data_file_message();
|
||||
|
||||
let export = match &mut self.state {
|
||||
ShellState::Ready(core) => {
|
||||
let package_json = match core.export_local_data_package_json() {
|
||||
Ok(package_json) => package_json,
|
||||
Err(error) => {
|
||||
self.set_local_data_file_error(error.to_string(), cx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
match core.snapshot() {
|
||||
Ok(snapshot) => Ok((snapshot.active_profile_name, package_json)),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
ShellState::StartupError(message) => Err(message.clone()),
|
||||
};
|
||||
|
||||
let (profile_name, package_json) = match export {
|
||||
Ok(export) => export,
|
||||
Err(error) => {
|
||||
self.set_local_data_file_error(error, cx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let directory = match default_export_directory() {
|
||||
Ok(directory) => directory,
|
||||
Err(error) => {
|
||||
self.set_local_data_file_error(error, cx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let suggested_name = local_data_export_filename(&profile_name);
|
||||
let prompt = cx.prompt_for_new_path(&directory, Some(&suggested_name));
|
||||
|
||||
cx.spawn_in(window, async move |shell, window| {
|
||||
let selected_path = match prompt.await {
|
||||
Ok(Ok(path)) => path,
|
||||
Ok(Err(error)) => {
|
||||
_ = shell.update_in(window, |shell, _, cx| {
|
||||
shell.set_local_data_file_error(error.to_string(), cx);
|
||||
});
|
||||
return;
|
||||
}
|
||||
Err(error) => {
|
||||
_ = shell.update_in(window, |shell, _, cx| {
|
||||
shell.set_local_data_file_error(error.to_string(), cx);
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(path) = selected_path else {
|
||||
return;
|
||||
};
|
||||
|
||||
let result = window
|
||||
.background_executor()
|
||||
.spawn(async move { write_local_data_package(path, package_json) })
|
||||
.await;
|
||||
_ = shell.update_in(window, |shell, _, cx| {
|
||||
shell.handle_local_data_export_result(result, cx);
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn ensure_privacy_security_surface(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.active_tab_matches_url(PRIVACY_SECURITY_URL) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.open_internal_tab(PRIVACY_SECURITY_URL, window, cx);
|
||||
}
|
||||
|
||||
fn clear_local_data_file_message(&mut self) {
|
||||
self.local_data_file_error = None;
|
||||
self.local_data_file_notice = None;
|
||||
}
|
||||
|
||||
fn set_local_data_file_error(&mut self, message: String, cx: &mut Context<Self>) {
|
||||
self.local_data_file_error = Some(message);
|
||||
self.local_data_file_notice = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_local_data_file_notice(&mut self, message: String, cx: &mut Context<Self>) {
|
||||
self.local_data_file_notice = Some(message);
|
||||
self.local_data_file_error = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn handle_local_data_export_result(
|
||||
&mut self,
|
||||
result: Result<PathBuf, String>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
match result {
|
||||
Ok(path) => self.set_local_data_file_notice(format!("Exported {}", path.display()), cx),
|
||||
Err(error) => self.set_local_data_file_error(error, cx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_export_directory() -> Result<PathBuf, String> {
|
||||
UserDirs::new()
|
||||
.and_then(|dirs| dirs.document_dir().map(Path::to_path_buf))
|
||||
.ok_or_else(|| "Documents directory is unavailable.".to_string())
|
||||
}
|
||||
|
||||
fn write_local_data_package(path: PathBuf, package_json: String) -> Result<PathBuf, String> {
|
||||
let path = normalize_export_path(path)?;
|
||||
fs::write(&path, package_json)
|
||||
.map_err(|error| format!("Unable to write {}: {error}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn normalize_export_path(mut path: PathBuf) -> Result<PathBuf, String> {
|
||||
if path.extension().is_none() {
|
||||
path.set_extension(ELYDATA_FILE_EXTENSION);
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
if path_has_elydata_extension(&path) {
|
||||
Ok(path)
|
||||
} else {
|
||||
Err("Export path must use .elydata extension.".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn path_has_elydata_extension(path: &Path) -> bool {
|
||||
path.extension().is_some_and(|extension| {
|
||||
extension.to_string_lossy().eq_ignore_ascii_case(ELYDATA_FILE_EXTENSION)
|
||||
})
|
||||
}
|
||||
|
||||
fn local_data_export_filename(profile_name: &str) -> String {
|
||||
let mut stem = String::new();
|
||||
let mut previous_separator = false;
|
||||
|
||||
for ch in profile_name.chars() {
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
|
||||
stem.push(ch);
|
||||
previous_separator = false;
|
||||
} else if !previous_separator {
|
||||
stem.push('-');
|
||||
previous_separator = true;
|
||||
}
|
||||
}
|
||||
|
||||
let stem = stem.trim_matches('-');
|
||||
let stem = if stem.is_empty() { "local-data" } else { stem };
|
||||
format!("{stem}.{ELYDATA_FILE_EXTENSION}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::{local_data_export_filename, normalize_export_path};
|
||||
|
||||
#[test]
|
||||
fn local_data_export_filename_sanitizes_profile_names() {
|
||||
assert_eq!(local_data_export_filename("Default"), "Default.elydata");
|
||||
assert_eq!(local_data_export_filename("Work / Client"), "Work-Client.elydata");
|
||||
assert_eq!(local_data_export_filename(" "), "local-data.elydata");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_export_path_adds_elydata_extension() -> Result<(), String> {
|
||||
let path = normalize_export_path(PathBuf::from("Default"))?;
|
||||
|
||||
assert_eq!(path, PathBuf::from("Default.elydata"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_export_path_rejects_other_extensions() {
|
||||
let error = normalize_export_path(PathBuf::from("Default.json"));
|
||||
|
||||
assert_eq!(error, Err("Export path must use .elydata extension.".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ mod downloads;
|
||||
mod focus;
|
||||
mod history;
|
||||
mod internal_pages;
|
||||
mod local_data_files;
|
||||
mod navigation;
|
||||
mod notes;
|
||||
mod plugins;
|
||||
@@ -74,6 +75,8 @@ pub struct ElyShell {
|
||||
bookmark_edit_error: Option<String>,
|
||||
bookmark_file_error: Option<String>,
|
||||
bookmark_file_notice: Option<String>,
|
||||
local_data_file_error: Option<String>,
|
||||
local_data_file_notice: Option<String>,
|
||||
plugin_install_error: Option<String>,
|
||||
pending_plugin_install: Option<PendingPluginInstall>,
|
||||
pending_plugin_uninstall: Option<PendingPluginUninstall>,
|
||||
@@ -161,6 +164,8 @@ impl ElyShell {
|
||||
bookmark_edit_error: None,
|
||||
bookmark_file_error: None,
|
||||
bookmark_file_notice: None,
|
||||
local_data_file_error: None,
|
||||
local_data_file_notice: None,
|
||||
plugin_install_error: None,
|
||||
pending_plugin_install: None,
|
||||
pending_plugin_uninstall: None,
|
||||
|
||||
@@ -21,6 +21,7 @@ use super::WebSurfaceStore;
|
||||
const LIVE_SURFACE_WIDTH: u32 = 934;
|
||||
const LIVE_SURFACE_HEIGHT: u32 = 657;
|
||||
const MINIMUM_CONTENT_PIXELS: u64 = 1_000;
|
||||
const LIVE_SITE_RENDER_ATTEMPTS: usize = 3;
|
||||
|
||||
#[test]
|
||||
fn web_surface_cases_cover_prd_reference_urls() -> Result<(), Box<dyn Error>> {
|
||||
@@ -38,12 +39,20 @@ fn web_surface_opens_and_renders_prd_reference_sites() -> Result<(), Box<dyn Err
|
||||
}
|
||||
|
||||
fn assert_web_surfaces_render(cases: &[LiveSiteCase]) -> Result<(), Box<dyn Error>> {
|
||||
let mut store = WebSurfaceStore::new();
|
||||
for case in cases {
|
||||
let tab = web_tab(case.url)?;
|
||||
let bounds = live_surface_bounds();
|
||||
let frame = render_web_surface_frame(case)?;
|
||||
log_prd_frame("web-surface", &frame, case);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
assert!(store.record_viewport_size(tab.id(), bounds), "{}", case.url);
|
||||
fn render_web_surface_frame(case: &LiveSiteCase) -> Result<WebSurfaceFrame, Box<dyn Error>> {
|
||||
let mut last_error = String::new();
|
||||
|
||||
for attempt in 0..LIVE_SITE_RENDER_ATTEMPTS {
|
||||
let mut store = WebSurfaceStore::new();
|
||||
let tab = web_tab(case.url)?;
|
||||
assert!(store.record_viewport_size(tab.id(), live_surface_bounds()), "{}", case.url);
|
||||
let request = store
|
||||
.prepare_request(&tab, ProfileDataMode::Persistent)
|
||||
.ok_or_else(|| format!("missing web surface request for {}", case.url))?;
|
||||
@@ -57,32 +66,54 @@ fn assert_web_surfaces_render(cases: &[LiveSiteCase]) -> Result<(), Box<dyn Erro
|
||||
snapshot,
|
||||
)?;
|
||||
|
||||
assert_prd_frame_is_ready(&frame, case);
|
||||
log_prd_frame("web-surface", &frame, case);
|
||||
store.finish(tab_id, WebSurfaceState::Ready(frame));
|
||||
let Some(WebSurfaceState::Ready(frame)) = store.state(tab.id()) else {
|
||||
return Err(format!("web surface state is not ready for {}", case.url).into());
|
||||
};
|
||||
assert_prd_frame_is_ready(frame, case);
|
||||
match validate_prd_frame(&frame, case) {
|
||||
Ok(()) => {
|
||||
store.finish(tab_id, WebSurfaceState::Ready(frame.clone()));
|
||||
let Some(WebSurfaceState::Ready(stored_frame)) = store.state(tab.id()) else {
|
||||
return Err(format!("web surface state is not ready for {}", case.url).into());
|
||||
};
|
||||
validate_prd_frame(stored_frame, case)?;
|
||||
return Ok(frame);
|
||||
}
|
||||
Err(error) => last_error = error,
|
||||
}
|
||||
|
||||
if attempt + 1 < LIVE_SITE_RENDER_ATTEMPTS {
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
Err(last_error.into())
|
||||
}
|
||||
|
||||
fn assert_prd_frame_is_ready(frame: &WebSurfaceFrame, case: &LiveSiteCase) {
|
||||
assert_eq!(
|
||||
frame.size(),
|
||||
WebSurfaceSize { width: LIVE_SURFACE_WIDTH, height: LIVE_SURFACE_HEIGHT },
|
||||
"{}",
|
||||
case.url
|
||||
);
|
||||
assert_eq!(frame.scroll_offset(), WebSurfaceScrollOffset::default(), "{}", case.url);
|
||||
assert_render_state_is_open(frame.render_state(), case.url);
|
||||
assert!(frame.url_label().contains(normalized_url(case.url)), "{}", frame.url_label());
|
||||
assert!(frame.title_label().contains(case.title_fragment), "{}", frame.title_label());
|
||||
assert_eq!(frame.detail_label(), format!("{} 934x657", frame.render_state()), "{}", case.url);
|
||||
assert!(frame.non_white_pixel_count() > 0, "{}", case.url);
|
||||
assert!(frame.content_pixel_count() >= MINIMUM_CONTENT_PIXELS, "{}", case.url);
|
||||
assert!(frame.sample_hash() > 0, "{}", case.url);
|
||||
fn validate_prd_frame(frame: &WebSurfaceFrame, case: &LiveSiteCase) -> Result<(), String> {
|
||||
require(
|
||||
frame.size() == WebSurfaceSize { width: LIVE_SURFACE_WIDTH, height: LIVE_SURFACE_HEIGHT },
|
||||
format!("{} size: {:?}", case.url, frame.size()),
|
||||
)?;
|
||||
require(
|
||||
frame.scroll_offset() == WebSurfaceScrollOffset::default(),
|
||||
format!("{} scroll: {:?}", case.url, frame.scroll_offset()),
|
||||
)?;
|
||||
require_render_state_is_open(frame.render_state(), case.url)?;
|
||||
require(
|
||||
frame.url_label().contains(normalized_url(case.url)),
|
||||
format!("url: {}", frame.url_label()),
|
||||
)?;
|
||||
require(
|
||||
frame.title_label().contains(case.title_fragment),
|
||||
format!("title: {}", frame.title_label()),
|
||||
)?;
|
||||
require(
|
||||
frame.detail_label() == format!("{} 934x657", frame.render_state()),
|
||||
format!("{} detail: {}", case.url, frame.detail_label()),
|
||||
)?;
|
||||
require(frame.non_white_pixel_count() > 0, case.url.to_string())?;
|
||||
require(
|
||||
frame.content_pixel_count() >= MINIMUM_CONTENT_PIXELS,
|
||||
format!("{} content pixels: {}", case.url, frame.content_pixel_count()),
|
||||
)?;
|
||||
require(frame.sample_hash() > 0, case.url.to_string())
|
||||
}
|
||||
|
||||
fn log_prd_frame(label: &str, frame: &WebSurfaceFrame, case: &LiveSiteCase) {
|
||||
@@ -100,8 +131,12 @@ fn log_prd_frame(label: &str, frame: &WebSurfaceFrame, case: &LiveSiteCase) {
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_render_state_is_open(state: &str, url: &str) {
|
||||
assert!(matches!(state, "complete" | "loading"), "{url} state: {state}");
|
||||
fn require_render_state_is_open(state: &str, url: &str) -> Result<(), String> {
|
||||
require(matches!(state, "complete" | "loading"), format!("{url} state: {state}"))
|
||||
}
|
||||
|
||||
fn require(condition: bool, message: String) -> Result<(), String> {
|
||||
if condition { Ok(()) } else { Err(message) }
|
||||
}
|
||||
|
||||
fn live_surface_bounds() -> Bounds<gpui::Pixels> {
|
||||
|
||||
Reference in New Issue
Block a user