Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs
T
kdletters 485ed50b26
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
AGC 七项交付效率优化并升级捆绑 Codex 到 0.155.1 (#439)
## 背景

净月潭案例(2026-09-19 21:53 → 09-20 01:26,3 小时 33 分)的问题不是模型慢,而是环境未就绪、验收靠模型自述、预算只约束单个工具入口、工具调用被 SDK 全局串行闸门卡住。本 PR 落地确认后的七项交付效率合同,并把捆绑 Codex 升到当前 npm latest。

## 结果

- 新建 Web 游戏在正式生成前由宿主自动预检:捆绑 Node/npm、真实 Vite 构建、受限浏览器桌面/移动截图;失败不启动生成或付费素材。
- 首个副作用前冻结交付合同,宿主保存权威证据与预算;证据齐全后先封口、排空并取得执行器完整退出证明,再产出交付报告,模型回复不再当作验收。
- 原生 shell、内置浏览器与托管命令、第三方 MCP 共用同一执行许可和累计执行时间;`validation.maxRuns` 改为执行/返修批次语义,另设 `maxTurnSeconds` 墙钟上限。
- 所有工具可并发:移除 SDK 全局串行的 `apply_patch`/`update_plan` 注册,改由宿主 `agc_apply_patch`(官方 parser + 当前回合写许可 + 受控进程树 + 短项目事务)和 `agc_update_plan` 提供等价能力;真实请求目录里已无串行注册,长 MCP 与补丁/计划实测在同一响应内重叠。
- 付费提交与本地写入绑定原回合原租约:容量与同动作锁等待可取消,封口、取消或预算耗尽后零新增提交;已越过提交边界的请求保留 operation ID 走 GET 对账,不自动重放。
- 新增请求与工具分段计时、有界并行批读和首轮上下文预取,未知耗时不补零。
- 捆绑 Codex 0.147.0 → 0.155.1:固定版本收敛到 `build_support/codex_bundle.rs` 单一声明,vendor 解析源码按 `rust-v0.155.1` 逐字节重取并更新 UPSTREAM 证据,适配 0.155 统一 exec(`exec_command`/`write_stdin`);macOS 侧车最小系统版本仍为 15.0。

## 验证

- 生产 CLI → 捆绑 Codex 0.155.1 → 本地 Responses/MCP 夹具 9/9:补丁、完成收尾、批次、只读/可写 MCP 并发、原生命令并发、原生资源并发、统一 exec 会话、期限终止(真实重叠 775 / 764 / 999 ms)。
- 真实模型目录 2/2;vendor 上游库 111 项;宿主定向与回归 106 项;真实 Windows 进程与取消 21 项;Node 侧门禁 52 项。
- 发行载荷:artifact-only 重建 NSIS,解包验证侧车清单 `codex-cli 0.155.1`、6 个组件哈希、打包后 `bin/codex.exe --version`、Node 运行时 2130 文件与双端 PNG、`--environment-check` ready。
- `check-config`、TypeScript、编码(4991 文件)、文档索引、`git diff --check`、`cargo fmt --check` 全部通过。

## 未覆盖

- 真实陶泥儿登录态 Provider 生成尚未执行(本机 `--llm-status` 返回 `authentication-required`)。
- macOS 侧车只在本机做静态 Mach-O 与清单解析,未在 macOS 上跑 `check-macos-bundle.mjs`。
- 全仓库聚合套件在本机仍因负载敏感的后台 mock-provider 用例而红,与本次改动无关:改动前的旧二进制同样失败,换单线程后相关用例 3/3 通过。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/439
2026-09-21 02:29:29 +08:00

2609 lines
100 KiB
Rust

use super::*;
use std::env;
use std::fs;
use std::net::Ipv4Addr;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::Duration;
use chromiumoxide::cdp::browser_protocol::fetch::RequestStage;
use chromiumoxide::cdp::browser_protocol::network::ResourceType;
use url::Url;
use super::capture::{
canvas_validation_diagnostic, classify_canvas_pixel_probe, preview_fetch_enable_params,
BrowserCanvasSnapshot, RENDERED_IMAGE_CAPTURE_SCRIPT,
};
use super::discovery::system_browser_candidates;
use super::evidence::browser_validation_result_for_report;
use super::model::{DEFAULT_SETTLE_MS, MAX_SETTLE_MS, REQUIRED_VIEWPORTS, RESULT_SCHEMA_VERSION};
use super::network_policy::{
preview_proxy_bypass_list, preview_request_decision, validate_input, PreviewRequestBlockReason,
PreviewRequestDecision,
};
use super::playtest::{
browser_playtest_assertions_passed, finish_generic_stability_observation,
generic_non_loss_progression_phase_is_valid, generic_primary_action_phase_is_valid,
generic_restart_phase_is_valid, generic_start_phase_is_valid, lane_enemy_state_changes,
parse_playable_web_game_state, validate_generic_stability_sample, LaneBattleProgress,
PlayableEnemyState, PlayableWebGameState, GENERIC_PLAYTEST_MAX_FINAL_SAMPLE_GAP,
GENERIC_PLAYTEST_POST_ACTION_MIN_STABILITY_SAMPLES, GENERIC_PLAYTEST_POST_ACTION_WINDOW,
GENERIC_PLAYTEST_RESTART_MIN_STABILITY_SAMPLES, GENERIC_PLAYTEST_RESTART_STABILITY_WINDOW,
GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SAMPLES,
GENERIC_PLAYTEST_START_OPPORTUNITY_WINDOW, PLAYABLE_GAME_STATE_SCHEMA_VERSION,
};
use super::process::create_browser_process_temp_dir;
static PATH_TEST_LOCK: Mutex<()> = Mutex::new(());
fn valid_input() -> BrowserValidationInput {
BrowserValidationInput {
url: "http://127.0.0.1:34567/".to_string(),
viewports: vec![
BrowserValidationViewport::Desktop,
BrowserValidationViewport::Mobile,
],
expected_text: vec!["开始游戏".to_string()],
settle_ms: DEFAULT_SETTLE_MS,
fail_on_console_error: true,
playtest_scenario: None,
evidence_root: env::temp_dir().join("browser-validation-test-evidence"),
}
}
fn canvas_snapshot(
visible_area: f64,
sample_count: u32,
non_empty_pixel_count: u32,
distinct_pixel_state_count: u32,
) -> BrowserCanvasSnapshot {
BrowserCanvasSnapshot {
width: 64,
height: 64,
css_width: 64.0,
css_height: 64.0,
visible_area,
sample_count,
non_empty_pixel_count,
distinct_pixel_state_count,
probe_error: None,
}
}
#[test]
fn rendered_image_capture_script_observes_canvas_and_webgl_without_cross_origin_urls() {
assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("CanvasRenderingContext2D"));
assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("drawImage"));
assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("texImage2D"));
assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("texSubImage2D"));
assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("url.origin !== location.origin"));
assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("url.pathname"));
}
fn generic_state(sequence: u64, phase: BrowserPlaytestPhase, level: u64) -> PlayableWebGameState {
PlayableWebGameState {
sequence,
phase,
level,
selected_defender_id: None,
defender_count: None,
enemies: None,
tetris: None,
}
}
fn lane_state(
sequence: u64,
phase: BrowserPlaytestPhase,
enemies: Vec<PlayableEnemyState>,
) -> PlayableWebGameState {
PlayableWebGameState {
sequence,
phase,
level: 1,
selected_defender_id: None,
defender_count: Some(1),
enemies: Some(enemies),
tetris: None,
}
}
#[test]
fn validates_loopback_url_and_rejects_external_urls() {
assert!(validate_input(&valid_input()).is_ok());
for url in [
"https://127.0.0.1:34567/",
"http://localhost:34567/",
"http://127.0.0.1/",
"http://127.0.0.1:34567/#fragment",
"https://example.com/",
] {
let mut input = valid_input();
input.url = url.to_string();
assert!(validate_input(&input).is_err(), "accepted {url}");
}
}
#[test]
fn validates_fixed_viewports_and_input_bounds() {
assert_eq!(
BrowserValidationViewport::Desktop.dimensions(),
(1280, 720, false)
);
assert_eq!(
BrowserValidationViewport::Mobile.dimensions(),
(390, 844, true)
);
let mut input = valid_input();
input.viewports.clear();
assert!(validate_input(&input).is_err());
for viewports in [
vec![BrowserValidationViewport::Desktop],
vec![BrowserValidationViewport::Mobile],
vec![
BrowserValidationViewport::Desktop,
BrowserValidationViewport::Desktop,
],
vec![
BrowserValidationViewport::Mobile,
BrowserValidationViewport::Mobile,
],
] {
input.viewports = viewports;
assert!(validate_input(&input).is_err());
}
input.viewports = vec![
BrowserValidationViewport::Mobile,
BrowserValidationViewport::Desktop,
];
assert!(validate_input(&input).is_ok());
input = valid_input();
input.settle_ms = MAX_SETTLE_MS + 1;
assert!(validate_input(&input).is_err());
input = valid_input();
input.expected_text = vec![" ".to_string()];
assert!(validate_input(&input).is_err());
input = valid_input();
input.evidence_root = PathBuf::from("relative/evidence");
assert!(validate_input(&input).is_err());
}
#[test]
fn deserialization_requires_exactly_desktop_and_mobile_viewports() {
for viewports in [
serde_json::json!(["desktop"]),
serde_json::json!(["mobile"]),
serde_json::json!(["desktop", "desktop"]),
serde_json::json!(["mobile", "mobile"]),
serde_json::json!(["desktop", "tablet"]),
serde_json::json!(["desktop", "mobile", "tablet"]),
] {
let value = serde_json::json!({
"url": "http://127.0.0.1:34567/",
"viewports": viewports,
"evidenceRoot": env::temp_dir().join("browser-validation-test-evidence")
});
assert!(
serde_json::from_value::<BrowserValidationInput>(value).is_err(),
"accepted invalid viewports {viewports}"
);
}
let input = serde_json::from_value::<BrowserValidationInput>(serde_json::json!({
"url": "http://127.0.0.1:34567/",
"viewports": ["mobile", "desktop"],
"evidenceRoot": env::temp_dir().join("browser-validation-test-evidence")
}))
.expect("deserialize both fixed viewports");
assert_eq!(
input.viewports,
vec![
BrowserValidationViewport::Mobile,
BrowserValidationViewport::Desktop
]
);
assert_eq!(input.settle_ms, DEFAULT_SETTLE_MS);
assert!(input.fail_on_console_error);
assert_eq!(input.playtest_scenario, None);
}
#[test]
fn playtest_input_accepts_only_fixed_scenario_names_and_rejects_custom_controls() {
for scenario in ["generic-v1", "tetris-v1", "lane-defense-v1"] {
let input = serde_json::from_value::<BrowserValidationInput>(serde_json::json!({
"url": "http://127.0.0.1:34567/",
"viewports": ["desktop", "mobile"],
"playtestScenario": scenario,
"evidenceRoot": env::temp_dir().join("browser-validation-test-evidence")
}))
.expect("deserialize fixed playtest scenario");
assert!(input.playtest_scenario.is_some());
}
for scenario in [
serde_json::json!("custom-v1"),
serde_json::json!({"scenario": "generic-v1", "selector": "#custom"}),
serde_json::json!({"scenario": "generic-v1", "script": "alert(1)"}),
serde_json::json!({"scenario": "generic-v1", "url": "https://example.test"}),
serde_json::json!({"scenario": "generic-v1", "headers": {"x-test": "1"}}),
serde_json::json!({"scenario": "generic-v1", "cookie": "session=test"}),
serde_json::json!({"scenario": "generic-v1", "actions": ["click"]}),
] {
let value = serde_json::json!({
"url": "http://127.0.0.1:34567/",
"viewports": ["desktop", "mobile"],
"playtestScenario": scenario,
"evidenceRoot": env::temp_dir().join("browser-validation-test-evidence")
});
assert!(
serde_json::from_value::<BrowserValidationInput>(value).is_err(),
"accepted custom playtest scenario input {scenario}"
);
}
for forbidden_field in [
"selector",
"script",
"playtestUrl",
"headers",
"cookie",
"actions",
] {
let mut value = serde_json::json!({
"url": "http://127.0.0.1:34567/",
"viewports": ["desktop", "mobile"],
"playtestScenario": "generic-v1",
"evidenceRoot": env::temp_dir().join("browser-validation-test-evidence")
});
value[forbidden_field] = serde_json::json!("custom");
assert!(
serde_json::from_value::<BrowserValidationInput>(value).is_err(),
"accepted forbidden field {forbidden_field}"
);
}
}
#[test]
fn playtest_scenario_fingerprints_are_fixed_lowercase_sha256_values() {
let generic = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::GenericV1);
let tetris = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::TetrisV1);
let lane = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::LaneDefenseV1);
assert_eq!(
generic,
"e5e1b4d01e39cb7b0dd2fd5a56982c3ddf825a926738d99152875ee1ff412a8d"
);
assert_eq!(
tetris,
"b3c49f43f70291ea5c9431a21bb751fead2e244c8e97fecec20ee3e38215a59c"
);
assert_eq!(
lane,
"6a24072ce7a570dd29edac0ca3fa905546140e44ca4ab6412d8fc7fe1239aa5a"
);
assert!(generic.bytes().all(|byte| byte.is_ascii_hexdigit()));
assert!(tetris.bytes().all(|byte| byte.is_ascii_hexdigit()));
assert!(lane.bytes().all(|byte| byte.is_ascii_hexdigit()));
assert_eq!(generic.len(), 64);
assert_eq!(tetris.len(), 64);
assert_eq!(lane.len(), 64);
assert_ne!(generic, lane);
assert_ne!(generic, tetris);
assert_ne!(tetris, lane);
}
#[test]
fn generic_playtest_contract_requires_ready_positive_initial_state_and_stable_phases() {
assert_eq!(
GENERIC_PLAYTEST_START_OPPORTUNITY_WINDOW,
Duration::from_secs(2)
);
assert_eq!(GENERIC_PLAYTEST_POST_ACTION_WINDOW, Duration::from_secs(3));
assert_eq!(
GENERIC_PLAYTEST_RESTART_STABILITY_WINDOW,
Duration::from_secs(3)
);
assert_eq!(
BrowserPlaytestScenario::GenericV1.assertion_names(),
[
"state-surface-valid",
"initial-phase-ready",
"level-positive",
"start-control-clicked",
"start-sequence-advanced",
"start-phase-playing",
"start-opportunity-stable-playing",
"primary-action-control-clicked",
"primary-action-sequence-advanced",
"primary-action-phase-playing-or-terminal",
"primary-action-outcome-valid",
"non-loss-progression-observed",
"restart-control-clicked",
"restart-sequence-advanced",
"restart-phase-ready-or-playing",
"restart-phase-stable-ready-or-playing",
]
);
assert_eq!(
BrowserPlaytestScenario::TetrisV1.assertion_names(),
BrowserPlaytestScenario::GenericV1.assertion_names()
);
assert!(generic_start_phase_is_valid(&generic_state(
2,
BrowserPlaytestPhase::Playing,
1,
)));
assert!(!generic_start_phase_is_valid(&generic_state(
2,
BrowserPlaytestPhase::Won,
1,
)));
for phase in [
BrowserPlaytestPhase::Playing,
BrowserPlaytestPhase::Won,
BrowserPlaytestPhase::Lost,
] {
assert!(generic_primary_action_phase_is_valid(&generic_state(
3, phase, 1,
)));
}
assert!(!generic_primary_action_phase_is_valid(&generic_state(
3,
BrowserPlaytestPhase::Ready,
1,
)));
assert!(generic_non_loss_progression_phase_is_valid(&generic_state(
3,
BrowserPlaytestPhase::Playing,
1,
)));
assert!(generic_non_loss_progression_phase_is_valid(&generic_state(
3,
BrowserPlaytestPhase::Won,
1,
)));
assert!(!generic_non_loss_progression_phase_is_valid(
&generic_state(3, BrowserPlaytestPhase::Lost, 1,)
));
assert!(generic_restart_phase_is_valid(&generic_state(
3,
BrowserPlaytestPhase::Ready,
1,
)));
assert!(!generic_restart_phase_is_valid(&generic_state(
3,
BrowserPlaytestPhase::Lost,
1,
)));
}
#[test]
fn generic_stability_samples_reject_terminal_phases_and_sequence_regression() {
let mut start_sequence = 2;
validate_generic_stability_sample(
&mut start_sequence,
&generic_state(3, BrowserPlaytestPhase::Playing, 1),
"start",
generic_start_phase_is_valid,
)
.expect("playing sample with monotonic sequence remains stable");
assert_eq!(start_sequence, 3);
let terminal_error = validate_generic_stability_sample(
&mut start_sequence,
&generic_state(4, BrowserPlaytestPhase::Lost, 1),
"start",
generic_start_phase_is_valid,
)
.expect_err("lost during start stability must fail");
assert!(terminal_error.contains("无效 phase"));
let regression_error = validate_generic_stability_sample(
&mut start_sequence,
&generic_state(2, BrowserPlaytestPhase::Playing, 1),
"start",
generic_start_phase_is_valid,
)
.expect_err("sequence regression during stability must fail");
assert!(regression_error.contains("回退"));
}
#[test]
fn generic_stability_requires_multiple_actual_samples_before_success() {
assert_eq!(
GENERIC_PLAYTEST_MAX_FINAL_SAMPLE_GAP,
Duration::from_millis(50)
);
assert_eq!(GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SAMPLES, 8);
assert_eq!(GENERIC_PLAYTEST_POST_ACTION_MIN_STABILITY_SAMPLES, 12);
assert_eq!(GENERIC_PLAYTEST_RESTART_MIN_STABILITY_SAMPLES, 12);
for (observation, minimum) in [
(
"start-opportunity",
GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SAMPLES,
),
(
"post-primary-action",
GENERIC_PLAYTEST_POST_ACTION_MIN_STABILITY_SAMPLES,
),
("restart", GENERIC_PLAYTEST_RESTART_MIN_STABILITY_SAMPLES),
] {
for sample_count in [0, minimum - 1] {
let error = finish_generic_stability_observation(
observation,
sample_count,
minimum,
Duration::ZERO,
Some(generic_state(2, BrowserPlaytestPhase::Playing, 1)),
)
.expect_err("too few actual stability samples must fail closed");
assert!(error.contains("样本不足"), "unexpected error: {error}");
}
}
let final_state = finish_generic_stability_observation(
"start-opportunity",
GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SAMPLES,
GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SAMPLES,
GENERIC_PLAYTEST_MAX_FINAL_SAMPLE_GAP,
Some(generic_state(4, BrowserPlaytestPhase::Playing, 1)),
)
.expect("minimum actual stability samples satisfy the count gate");
assert_eq!(final_state.sequence, 4);
let stale_tail_error = finish_generic_stability_observation(
"start-opportunity",
GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SAMPLES,
GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SAMPLES,
GENERIC_PLAYTEST_MAX_FINAL_SAMPLE_GAP + Duration::from_millis(1),
Some(generic_state(4, BrowserPlaytestPhase::Playing, 1)),
)
.expect_err("stale final stability sample must fail closed");
assert!(stale_tail_error.contains("末端样本"));
}
#[test]
fn generic_restart_progress_uses_the_final_stability_sequence_as_baseline() {
let started = generic_state(2, BrowserPlaytestPhase::Playing, 1);
let mut final_stability_sequence = started.sequence;
for sequence in [3, 4, 5] {
validate_generic_stability_sample(
&mut final_stability_sequence,
&generic_state(sequence, BrowserPlaytestPhase::Playing, 1),
"start",
generic_start_phase_is_valid,
)
.expect("start remains playing while sequence advances");
}
let restart_without_progress = generic_state(5, BrowserPlaytestPhase::Playing, 1);
assert!(restart_without_progress.sequence > started.sequence);
assert!(restart_without_progress.sequence <= final_stability_sequence);
}
#[test]
fn playable_state_accepts_all_fixed_phases_and_u64_boundaries() {
for phase in ["ready", "playing", "won", "lost"] {
let state = parse_playable_web_game_state(
&serde_json::json!({
"schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION,
"sequence": u64::MAX,
"phase": phase,
"level": 0
})
.to_string(),
BrowserPlaytestScenario::GenericV1,
)
.expect("parse valid generic state");
assert_eq!(state.sequence, u64::MAX);
assert_eq!(state.level, 0);
}
for invalid in [
r#"{"schemaVersion":"playable-web-game-state.v0","sequence":0,"phase":"ready","level":0}"#,
r#"{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"paused","level":0}"#,
r#"{"schemaVersion":"playable-web-game-state.v1","sequence":-1,"phase":"ready","level":0}"#,
r#"{"schemaVersion":"playable-web-game-state.v1","sequence":1.5,"phase":"ready","level":0}"#,
r#"{"schemaVersion":"playable-web-game-state.v1","sequence":18446744073709551616,"phase":"ready","level":0}"#,
r#"{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"ready","level":-1}"#,
r#"{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"ready","level":1.5}"#,
r#"{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"ready","level":18446744073709551616}"#,
] {
assert!(
parse_playable_web_game_state(invalid, BrowserPlaytestScenario::GenericV1).is_err(),
"accepted invalid state {invalid}"
);
}
}
#[test]
fn generic_playable_state_validates_optional_tetris_behavior_telemetry() {
let valid = serde_json::json!({
"schemaVersion": "playable-web-game-state.v1",
"sequence": 7,
"phase": "playing",
"level": 1,
"gameplay": {
"kind": "tetris",
"activePieceId": "piece-7",
"rotation": 3,
"row": 8,
"lockedPieces": 2,
"lineClearChecks": 2,
"clearedLines": 1,
"occupiedCells": 9,
"ghostRow": 17,
"nextPiece": "L"
}
});
let state =
parse_playable_web_game_state(&valid.to_string(), BrowserPlaytestScenario::GenericV1)
.expect("parse bounded tetris telemetry");
let tetris = state.tetris.expect("tetris telemetry exists");
assert_eq!(tetris.active_piece_id, "piece-7");
assert_eq!(tetris.rotation, 3);
assert_eq!(tetris.row, 8);
assert_eq!(tetris.locked_pieces, 2);
assert_eq!(tetris.line_clear_checks, 2);
assert_eq!(tetris.cleared_lines, 1);
assert_eq!(tetris.occupied_cells, 9);
parse_playable_web_game_state(&valid.to_string(), BrowserPlaytestScenario::TetrisV1)
.expect("tetris-v1 requires and accepts the bounded gameplay contract");
let mut missing_gameplay = valid.clone();
missing_gameplay
.as_object_mut()
.expect("playable state object")
.remove("gameplay");
assert!(parse_playable_web_game_state(
&missing_gameplay.to_string(),
BrowserPlaytestScenario::TetrisV1,
)
.is_err());
for invalid_gameplay in [
serde_json::json!({"kind":"tetris","activePieceId":"piece-1","rotation":4,"row":0,"lockedPieces":0,"lineClearChecks":0,"clearedLines":0,"occupiedCells":0}),
serde_json::json!({"kind":"tetris","activePieceId":"","rotation":0,"row":0,"lockedPieces":0,"lineClearChecks":0,"clearedLines":0,"occupiedCells":0}),
serde_json::json!({"kind":"tetris","activePieceId":"piece-1","rotation":0,"row":0,"lockedPieces":0,"lineClearChecks":0,"clearedLines":0}),
] {
let mut invalid = valid.clone();
invalid["gameplay"] = invalid_gameplay;
assert!(
parse_playable_web_game_state(
&invalid.to_string(),
BrowserPlaytestScenario::GenericV1,
)
.is_err(),
"accepted invalid tetris telemetry {invalid}"
);
}
for non_tetris_gameplay in [
serde_json::json!({"kind":"collection","score":7}),
serde_json::json!("collection"),
serde_json::json!(["collection", 7]),
] {
let mut non_tetris_extension = valid.clone();
non_tetris_extension["gameplay"] = non_tetris_gameplay;
let generic = parse_playable_web_game_state(
&non_tetris_extension.to_string(),
BrowserPlaytestScenario::GenericV1,
)
.expect("generic-v1 must ignore non-Tetris gameplay extensions");
assert!(generic.tetris.is_none());
assert!(parse_playable_web_game_state(
&non_tetris_extension.to_string(),
BrowserPlaytestScenario::TetrisV1,
)
.is_err());
}
}
#[test]
fn lane_defense_state_requires_bounded_selection_defenders_and_enemy_metrics() {
let valid = serde_json::json!({
"schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION,
"sequence": 7,
"phase": "playing",
"level": 2,
"selectedDefenderId": null,
"defenders": [],
"enemies": [{
"id": "enemy-1",
"lane": 0,
"position": -1.25,
"health": 5.5,
"maxHealth": 10
}]
});
let state =
parse_playable_web_game_state(&valid.to_string(), BrowserPlaytestScenario::LaneDefenseV1)
.expect("parse valid lane-defense state");
assert_eq!(state.defender_count, Some(0));
assert_eq!(state.enemies.as_ref().map(Vec::len), Some(1));
for invalid in [
serde_json::json!({
"schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION,
"sequence": 0, "phase": "ready", "level": 0,
"defenders": [], "enemies": []
}),
serde_json::json!({
"schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION,
"sequence": 0, "phase": "ready", "level": 0,
"selectedDefenderId": null, "defenders": {}, "enemies": []
}),
serde_json::json!({
"schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION,
"sequence": 0, "phase": "playing", "level": 0,
"selectedDefenderId": "defender-1", "defenders": [],
"enemies": [{"id": "enemy-1", "lane": -1, "position": 0, "health": 1, "maxHealth": 1}]
}),
serde_json::json!({
"schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION,
"sequence": 0, "phase": "playing", "level": 0,
"selectedDefenderId": "defender-1", "defenders": [],
"enemies": [{"id": "enemy-1", "lane": "top", "position": 0, "health": -1, "maxHealth": 1}]
}),
serde_json::json!({
"schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION,
"sequence": 0, "phase": "playing", "level": 0,
"selectedDefenderId": "defender-1", "defenders": [],
"enemies": [{"id": "enemy-1", "lane": "top", "position": 0, "health": 2, "maxHealth": 1}]
}),
serde_json::json!({
"schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION,
"sequence": 0, "phase": "playing", "level": 0,
"selectedDefenderId": "defender-1", "defenders": [],
"enemies": [{"id": "enemy-1", "lane": "top", "position": 0, "health": 0, "maxHealth": 0}]
}),
serde_json::json!({
"schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION,
"sequence": 0, "phase": "playing", "level": 0,
"selectedDefenderId": "defender-1", "defenders": [],
"enemies": [{"id": "enemy-1", "lane": "top", "health": 1, "maxHealth": 1}]
}),
] {
assert!(
parse_playable_web_game_state(
&invalid.to_string(),
BrowserPlaytestScenario::LaneDefenseV1,
)
.is_err(),
"accepted invalid lane-defense state {invalid}"
);
}
}
#[test]
fn lane_enemy_disappearance_counts_as_health_reaching_zero() {
let previous = lane_state(
10,
BrowserPlaytestPhase::Playing,
vec![PlayableEnemyState {
id: "enemy-1".to_string(),
position: 80.0,
health: 4.0,
}],
);
let current = lane_state(11, BrowserPlaytestPhase::Won, Vec::new());
assert_eq!(lane_enemy_state_changes(&previous, &current), (false, true));
}
#[test]
fn lane_battle_progress_requires_monotonic_sequence_and_real_changes() {
let initial = lane_state(
20,
BrowserPlaytestPhase::Playing,
vec![PlayableEnemyState {
id: "enemy-1".to_string(),
position: 100.0,
health: 10.0,
}],
);
let moved = lane_state(
21,
BrowserPlaytestPhase::Playing,
vec![PlayableEnemyState {
id: "enemy-1".to_string(),
position: 60.0,
health: 10.0,
}],
);
let won = lane_state(22, BrowserPlaytestPhase::Won, Vec::new());
let mut progress = LaneBattleProgress::new(initial.clone());
progress.observe(&moved);
progress.observe(&won);
assert!(progress.completed(&won));
assert!(progress.sequence_advanced);
assert!(progress.sequence_monotonic);
assert!(progress.enemy_position_changed);
assert!(progress.enemy_health_decreased);
let regressed = lane_state(19, BrowserPlaytestPhase::Won, Vec::new());
let mut regressed_progress = LaneBattleProgress::new(initial);
regressed_progress.observe(&moved);
regressed_progress.observe(&regressed);
assert!(!regressed_progress.completed(&regressed));
assert!(!regressed_progress.sequence_monotonic);
}
#[test]
fn playtest_assertion_summary_requires_every_assertion_and_no_diagnostics() {
let mut assertions = BrowserPlaytestScenario::GenericV1
.assertion_names()
.iter()
.map(|name| BrowserPlaytestAssertion {
name: (*name).to_string(),
passed: true,
})
.collect::<Vec<_>>();
assert!(browser_playtest_assertions_passed(&assertions, &[]));
assertions[0].passed = false;
assert!(!browser_playtest_assertions_passed(&assertions, &[]));
assertions[0].passed = true;
assert!(!browser_playtest_assertions_passed(
&assertions,
&["固定诊断".to_string()]
));
assert!(!browser_playtest_assertions_passed(&[], &[]));
}
#[test]
fn classifies_only_multiple_meaningful_canvas_pixel_states_as_non_empty() {
assert_eq!(classify_canvas_pixel_probe(0, 0, 0), None);
assert_eq!(classify_canvas_pixel_probe(4_096, 0, 1), Some(false));
assert_eq!(classify_canvas_pixel_probe(4_096, 4_096, 1), Some(false));
assert_eq!(classify_canvas_pixel_probe(4_096, 2_048, 2), Some(true));
assert_eq!(classify_canvas_pixel_probe(4_096, 4_096, 2), Some(true));
assert_eq!(classify_canvas_pixel_probe(4, 5, 2), Some(false));
assert_eq!(classify_canvas_pixel_probe(4, 4, 5), Some(false));
}
#[test]
fn requires_a_visible_canvas_with_meaningful_pixel_states() {
let transparent = canvas_snapshot(4_096.0, 4_096, 0, 1).into_evidence();
let solid = canvas_snapshot(4_096.0, 4_096, 4_096, 1).into_evidence();
let drawn = canvas_snapshot(4_096.0, 4_096, 4_096, 2).into_evidence();
let hidden_drawn = canvas_snapshot(0.0, 4_096, 4_096, 2).into_evidence();
assert_eq!(transparent.non_empty, Some(false));
assert_eq!(solid.non_empty, Some(false));
assert_eq!(drawn.non_empty, Some(true));
assert_eq!(canvas_validation_diagnostic(&[]), Some("未发现可见 canvas"));
assert_eq!(
canvas_validation_diagnostic(&[hidden_drawn]),
Some("未发现可见 canvas")
);
assert_eq!(
canvas_validation_diagnostic(&[transparent.clone()]),
Some("可见 canvas 未探测到至少两种有意义的像素颜色/alpha 状态")
);
assert_eq!(
canvas_validation_diagnostic(&[solid.clone()]),
Some("可见 canvas 未探测到至少两种有意义的像素颜色/alpha 状态")
);
assert_eq!(
canvas_validation_diagnostic(&[transparent, solid]),
Some("可见 canvas 未探测到至少两种有意义的像素颜色/alpha 状态")
);
assert_eq!(canvas_validation_diagnostic(&[drawn]), None);
}
#[test]
fn canvas_probe_serialization_keeps_the_v1_output_structure() {
let value = serde_json::to_value(canvas_snapshot(4_096.0, 4_096, 4_096, 2).into_evidence())
.expect("serialize canvas evidence");
let object = value.as_object().expect("canvas evidence object");
assert_eq!(object.len(), 9);
for field in [
"width",
"height",
"cssWidth",
"cssHeight",
"visibleArea",
"sampleCount",
"nonEmptyPixelCount",
"nonEmpty",
"probeError",
] {
assert!(object.contains_key(field), "missing output field {field}");
}
assert!(!object.contains_key("distinctPixelStateCount"));
}
#[test]
fn gameplay_requires_distinct_desktop_and_mobile_results_for_the_requested_scenario() {
let playtest = BrowserPlaytestResult {
scenario: BrowserPlaytestScenario::GenericV1,
scenario_fingerprint: browser_playtest_scenario_fingerprint(
BrowserPlaytestScenario::GenericV1,
),
passed: true,
initial_sequence: Some(0),
initial_phase: Some(BrowserPlaytestPhase::Ready),
initial_level: Some(1),
final_sequence: Some(3),
final_phase: Some(BrowserPlaytestPhase::Ready),
final_level: Some(1),
assertions: Vec::new(),
diagnostics: Vec::new(),
runner_evidence: None,
};
let mut results = REQUIRED_VIEWPORTS
.into_iter()
.map(|viewport| BrowserViewportPlaytestResult {
viewport,
result: playtest.clone(),
})
.collect::<Vec<_>>();
assert!(required_viewport_playtests_passed(
BrowserPlaytestScenario::GenericV1,
&results
));
assert!(!required_viewport_playtests_passed(
BrowserPlaytestScenario::GenericV1,
&results[..1]
));
results[1].viewport = BrowserValidationViewport::Desktop;
assert!(!required_viewport_playtests_passed(
BrowserPlaytestScenario::GenericV1,
&results
));
results[1].viewport = BrowserValidationViewport::Mobile;
results[1].result.passed = false;
assert!(!required_viewport_playtests_passed(
BrowserPlaytestScenario::GenericV1,
&results
));
results[1].result.passed = true;
assert!(!required_viewport_playtests_passed(
BrowserPlaytestScenario::TetrisV1,
&results
));
let legacy = serde_json::json!({
"schemaVersion": RESULT_SCHEMA_VERSION, "url": "http://127.0.0.1:34567/", "passed": true,
"browser": {"kind":"chrome","product":"Chrome/1","protocolVersion":"1.3"},
"viewportResults": [], "playtest": playtest, "diagnostics": [],
"evidence": {"root":".","reportPath":"validation.json"}, "completedAtUnixMs":1,
});
let legacy: BrowserValidationResult = serde_json::from_value(legacy).unwrap();
assert!(legacy.playtest.as_ref().unwrap().passed);
assert!(
legacy.viewport_playtests.is_empty(),
"legacy desktop evidence cannot manufacture mobile success"
);
assert!(!required_viewport_playtests_passed(
BrowserPlaytestScenario::GenericV1,
&legacy.viewport_playtests
));
}
#[test]
fn result_serializes_with_camel_case_evidence_paths() {
let result = BrowserValidationResult {
schema_version: RESULT_SCHEMA_VERSION.to_string(),
url: "http://127.0.0.1:34567/".to_string(),
browser: BrowserIdentity {
kind: DiscoveredBrowserKind::Chrome,
product: "Chrome/1".to_string(),
protocol_version: "1.3".to_string(),
},
passed: true,
viewport_results: Vec::new(),
playtest: None,
viewport_playtests: Vec::new(),
diagnostics: Vec::new(),
evidence: BrowserValidationEvidencePaths {
root: PathBuf::from("/tmp/evidence"),
report_path: PathBuf::from("/tmp/evidence/validation.json"),
},
completed_at_unix_ms: 1,
};
let value = serde_json::to_value(&result).expect("serialize result");
assert_eq!(value["schemaVersion"], RESULT_SCHEMA_VERSION);
assert_eq!(value["completedAtUnixMs"], 1);
assert_eq!(
value["evidence"]["reportPath"],
"/tmp/evidence/validation.json"
);
assert!(value.get("playtest").is_none());
assert!(value.get("viewportPlaytests").is_none());
let legacy: BrowserValidationResult = serde_json::from_value(value.clone()).unwrap();
assert!(legacy.viewport_playtests.is_empty());
assert!(!required_viewport_playtests_passed(
BrowserPlaytestScenario::GenericV1,
&legacy.viewport_playtests
));
assert_eq!(
serde_json::from_value::<BrowserValidationResult>(value)
.expect("deserialize static result")
.playtest,
None
);
}
#[test]
fn persisted_report_uses_only_relative_evidence_paths() {
let evidence_root = std::env::temp_dir().join("browser-evidence");
let result = BrowserValidationResult {
schema_version: RESULT_SCHEMA_VERSION.to_string(),
url: "http://127.0.0.1:34567/".to_string(),
browser: BrowserIdentity {
kind: DiscoveredBrowserKind::Chrome,
product: "Chrome/1".to_string(),
protocol_version: "1.3".to_string(),
},
passed: true,
viewport_results: vec![BrowserViewportValidationResult {
viewport: BrowserValidationViewport::Desktop,
width: 1440,
height: 900,
final_url: "http://127.0.0.1:34567/".to_string(),
title: "fixture".to_string(),
ready_state: "complete".to_string(),
visible_text_summary: "fixture".to_string(),
visible_text_character_count: 7,
dom_character_count: 7,
expected_text: Vec::new(),
console_errors: Vec::new(),
console_warnings: Vec::new(),
exceptions: Vec::new(),
failed_requests: Vec::new(),
canvases: Vec::new(),
blocked_popup_count: 0,
blocked_dialog_count: 0,
blocked_download_count: 0,
blocked_permission_count: 0,
blocked_service_worker_count: 0,
screenshot_path: evidence_root.join("desktop.png"),
passed: true,
diagnostics: Vec::new(),
}],
playtest: None,
viewport_playtests: Vec::new(),
diagnostics: Vec::new(),
evidence: BrowserValidationEvidencePaths {
root: evidence_root.clone(),
report_path: evidence_root.join("validation.json"),
},
completed_at_unix_ms: 1,
};
let persisted =
browser_validation_result_for_report(&result).expect("build relative browser report");
assert_eq!(persisted.evidence.root, PathBuf::from("."));
assert_eq!(
persisted.evidence.report_path,
PathBuf::from("validation.json")
);
assert_eq!(
persisted.viewport_results[0].screenshot_path,
PathBuf::from("desktop.png")
);
assert_eq!(result.evidence.root, evidence_root);
assert!(result.viewport_results[0].screenshot_path.is_absolute());
}
#[test]
fn browser_discovery_does_not_trust_a_path_candidate() {
let _guard = PATH_TEST_LOCK.lock().expect("path test lock");
let directory = tempfile::tempdir().expect("fake browser directory");
let executable_name = if cfg!(target_os = "windows") {
"chrome.exe"
} else {
"google-chrome"
};
let fake_browser = directory.path().join(executable_name);
fs::write(&fake_browser, b"not a trusted browser").expect("fake browser");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = fs::metadata(&fake_browser)
.expect("fake browser metadata")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&fake_browser, permissions).expect("fake browser permissions");
}
let fake_browser = fake_browser.canonicalize().expect("canonical fake browser");
let original_path = env::var_os("PATH");
env::set_var("PATH", directory.path());
let discovered = discover_chrome_or_edge().ok();
if let Some(original_path) = original_path {
env::set_var("PATH", original_path);
} else {
env::remove_var("PATH");
}
assert_ne!(
discovered.map(|browser| browser.executable_path),
Some(fake_browser)
);
}
#[test]
fn browser_discovery_only_builds_absolute_system_candidates() {
assert!(system_browser_candidates()
.iter()
.all(|(path, _kind)| path.is_absolute()));
}
#[cfg(unix)]
#[test]
fn browser_process_temp_dir_keeps_chrome_singleton_socket_path_short() {
use std::os::unix::ffi::OsStrExt;
let directory = create_browser_process_temp_dir().expect("browser process tempdir");
let singleton_socket = directory
.path()
.join("com.google.Chrome.XXXXXX/SingletonSocket");
assert!(directory.path().starts_with("/tmp"));
assert!(singleton_socket.as_os_str().as_bytes().len() < 108);
}
#[test]
fn fetch_interception_covers_all_resources_before_the_request_is_sent() {
let params = preview_fetch_enable_params();
let patterns = params.patterns.expect("fetch interception patterns");
assert_eq!(patterns.len(), 1);
assert_eq!(patterns[0].url_pattern.as_deref(), Some("*"));
assert_eq!(patterns[0].resource_type, None);
assert_eq!(patterns[0].request_stage, Some(RequestStage::Request));
}
#[test]
fn request_policy_blocks_cross_origin_http_redirects_and_websockets() {
let origin = Url::parse("http://127.0.0.1:34567/").expect("preview origin");
assert_eq!(
preview_request_decision(
"http://127.0.0.1:34567/game.js",
&ResourceType::Script,
false,
&origin,
),
PreviewRequestDecision::Allow
);
assert_eq!(
preview_request_decision(
"ws://127.0.0.1:34567/socket",
&ResourceType::WebSocket,
false,
&origin,
),
PreviewRequestDecision::Allow
);
for url in [
"ws://127.0.0.1:34568/socket",
"ws://example.com/socket",
"wss://127.0.0.1:34567/socket",
"http://127.0.0.1:34567/not-a-websocket",
] {
assert_eq!(
preview_request_decision(url, &ResourceType::WebSocket, false, &origin),
PreviewRequestDecision::Block(PreviewRequestBlockReason::WebSocketBeforeHandshake),
"accepted WebSocket request {url}"
);
}
assert_eq!(
preview_request_decision(
"http://127.0.0.1:34568/private",
&ResourceType::Fetch,
false,
&origin,
),
PreviewRequestDecision::Block(PreviewRequestBlockReason::CrossOrigin)
);
assert_eq!(
preview_request_decision(
"https://example.com/redirect-target",
&ResourceType::Document,
true,
&origin,
),
PreviewRequestDecision::Block(PreviewRequestBlockReason::RedirectTarget)
);
}
#[test]
fn proxy_bypass_is_limited_to_the_preview_http_and_websocket_origin() {
let origin = Url::parse("http://127.0.0.1:34567/").expect("preview origin");
assert_eq!(
preview_proxy_bypass_list(&origin),
"<-loopback>;http://127.0.0.1:34567;ws://127.0.0.1:34567"
);
}
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_blocks_http_redirect_and_websocket_before_connection() {
use std::io::{ErrorKind, Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use std::thread;
discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed");
let blocked_listener =
TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind blocked origin");
let blocked_port = blocked_listener
.local_addr()
.expect("blocked origin address")
.port();
blocked_listener
.set_nonblocking(true)
.expect("nonblocking blocked origin");
let preview_listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview");
let preview_port = preview_listener
.local_addr()
.expect("preview address")
.port();
preview_listener
.set_nonblocking(true)
.expect("nonblocking preview");
let html = format!(
r#"<!doctype html><html><body><main>Network policy probe</main><script>
fetch('http://127.0.0.1:{blocked_port}/direct').catch(() => {{}});
fetch('/redirect').catch(() => {{}});
const socket = new WebSocket('ws://127.0.0.1:{blocked_port}/socket');
socket.onerror = () => {{ document.body.dataset.websocket = 'blocked'; }};
</script></body></html>"#
)
.into_bytes();
let (stop_tx, stop_rx) = mpsc::channel();
let server = thread::spawn(move || {
while stop_rx.try_recv().is_err() {
match preview_listener.accept() {
Ok((mut stream, _)) => {
let mut request = [0_u8; 2048];
let count = stream.read(&mut request).unwrap_or_default();
let request = String::from_utf8_lossy(&request[..count]);
if request.starts_with("GET /redirect ") {
let response = format!(
"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:{blocked_port}/redirected\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
let _ = stream.write_all(response.as_bytes());
} else {
let headers = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
html.len()
);
let _ = stream.write_all(headers.as_bytes());
let _ = stream.write_all(&html);
}
}
Err(error) if error.kind() == ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
}
Err(error) => panic!("preview accept failed: {error}"),
}
}
});
let evidence = tempfile::tempdir().expect("evidence tempdir");
let validation = validate_local_preview_in_browser(BrowserValidationInput {
url: format!("http://127.0.0.1:{preview_port}/"),
viewports: REQUIRED_VIEWPORTS.to_vec(),
expected_text: vec!["Network policy probe".to_string()],
settle_ms: 200,
fail_on_console_error: false,
playtest_scenario: None,
evidence_root: evidence.path().join("evidence"),
})
.await;
let _ = stop_tx.send(());
server.join().expect("preview server");
let result = validation.expect("real browser network validation");
assert_eq!(result.viewport_results.len(), REQUIRED_VIEWPORTS.len());
assert!(result.viewport_results.iter().all(|viewport| {
!viewport.passed
&& viewport
.diagnostics
.iter()
.any(|diagnostic| diagnostic == "未发现可见 canvas")
}));
let failed_requests = &result.viewport_results[0].failed_requests;
assert!(!result.passed);
assert!(
failed_requests.iter().any(|request| {
request.blocked_by_policy
&& request.url == format!("http://127.0.0.1:{blocked_port}/direct")
&& request.error_text == PreviewRequestBlockReason::CrossOrigin.message()
}),
"{failed_requests:#?}"
);
assert!(
failed_requests.iter().any(|request| {
request.blocked_by_policy
&& request.url == format!("http://127.0.0.1:{blocked_port}/redirected")
}),
"{failed_requests:#?}"
);
assert!(
failed_requests.iter().any(|request| {
request.blocked_by_policy
&& request.resource_type == "WebSocket"
&& request.error_text
== PreviewRequestBlockReason::WebSocketBeforeHandshake.message()
}),
"{failed_requests:#?}"
);
thread::sleep(Duration::from_millis(100));
match blocked_listener.accept() {
Err(error) if error.kind() == ErrorKind::WouldBlock => {}
Ok(_) => panic!("blocked origin received a TCP connection"),
Err(error) => panic!("blocked origin accept failed: {error}"),
}
}
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_generic_playtest_rejects_one_frame_playing_state() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use std::thread;
discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed");
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview");
let port = listener.local_addr().expect("preview address").port();
listener.set_nonblocking(true).expect("nonblocking preview");
let html = br#"<!doctype html>
<html>
<head><meta charset="utf-8"><link rel="icon" href="data:"><title>Transient Generic Browser Fixture</title></head>
<body>
<main>Transient generic fixture</main>
<canvas id="stage" width="320" height="180" style="width:320px;max-width:100%;height:auto"></canvas>
<button type="button" data-playtest-id="start">Start</button>
<button type="button" data-playtest-id="primary-action">Jump</button>
<button type="button" data-playtest-id="restart">Restart</button>
<script id="playable-web-game-state" type="application/json">{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"ready","level":1}</script>
<script>
(() => {
'use strict';
const stateNode = document.querySelector('#playable-web-game-state');
const state = JSON.parse(stateNode.textContent);
const context = document.querySelector('#stage').getContext('2d');
context.fillStyle = '#0f172a';
context.fillRect(0, 0, 320, 180);
context.fillStyle = '#ef4444';
context.fillRect(48, 48, 96, 64);
let generation = 0;
const publish = () => { stateNode.textContent = JSON.stringify(state); };
const advance = (phase) => {
state.phase = phase;
state.sequence += 1;
publish();
};
const transientPlay = () => {
const currentGeneration = ++generation;
advance('playing');
setTimeout(() => {
if (currentGeneration === generation) advance('lost');
}, 400);
};
document.querySelector('[data-playtest-id="start"]').addEventListener('click', (event) => {
if (event.isTrusted) transientPlay();
});
document.querySelector('[data-playtest-id="primary-action"]').addEventListener('click', (event) => {
if (event.isTrusted && state.phase === 'playing') advance('playing');
});
document.querySelector('[data-playtest-id="restart"]').addEventListener('click', (event) => {
if (event.isTrusted) transientPlay();
});
})();
</script>
</body>
</html>"#;
let (stop_tx, stop_rx) = mpsc::channel();
let server = thread::spawn(move || {
while stop_rx.try_recv().is_err() {
match listener.accept() {
Ok((mut stream, _)) => {
let mut request = [0_u8; 2048];
let _ = stream.read(&mut request);
let headers = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
html.len()
);
let _ = stream.write_all(headers.as_bytes());
let _ = stream.write_all(html);
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
}
Err(error) => panic!("preview accept failed: {error}"),
}
}
});
let evidence = tempfile::tempdir().expect("evidence tempdir");
let validation = validate_local_preview_in_browser(BrowserValidationInput {
url: format!("http://127.0.0.1:{port}/"),
viewports: REQUIRED_VIEWPORTS.to_vec(),
expected_text: vec!["Transient generic fixture".to_string()],
settle_ms: 100,
fail_on_console_error: true,
playtest_scenario: Some(BrowserPlaytestScenario::GenericV1),
evidence_root: evidence.path().join("evidence"),
})
.await;
let _ = stop_tx.send(());
server.join().expect("preview server");
let result = validation.expect("real generic browser validation");
assert!(
!result.passed,
"transient playing state must fail validation"
);
let playtest = result.playtest.expect("generic playtest result");
assert!(!playtest.passed, "{:#?}", playtest.assertions);
assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Lost));
}
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_generic_playtest_accepts_earlier_window_capture_stop_immediate_flow() {
let result = run_stable_generic_viewport_fixture(false).await;
assert!(
result.passed,
"diagnostics={:#?}\nviewports={:#?}",
result.diagnostics, result.viewport_results
);
assert!(required_viewport_playtests_passed(
BrowserPlaytestScenario::GenericV1,
&result.viewport_playtests
));
assert_eq!(result.viewport_playtests.len(), 2);
for viewport in &result.viewport_playtests {
let playtest = &viewport.result;
assert!(
playtest.passed,
"{:?}: {:#?}",
viewport.viewport, playtest.diagnostics
);
assert_eq!(playtest.initial_sequence, Some(0));
assert_eq!(playtest.final_sequence, Some(3));
assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Ready));
assert!(playtest.assertions.iter().all(|assertion| assertion.passed));
}
let desktop = result
.viewport_playtests
.iter()
.find(|entry| entry.viewport == BrowserValidationViewport::Desktop)
.unwrap();
assert_eq!(result.playtest.as_ref(), Some(&desktop.result));
}
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_generic_playtest_rejects_mobile_only_unwired_control() {
let result = run_stable_generic_viewport_fixture(true).await;
assert!(
!result.passed,
"a successful desktop cannot hide mobile gameplay failure"
);
assert!(
result.playtest.as_ref().unwrap().passed,
"legacy field retains desktop result"
);
assert_eq!(result.viewport_playtests.len(), 2);
let mobile = result
.viewport_playtests
.iter()
.find(|entry| entry.viewport == BrowserValidationViewport::Mobile)
.unwrap();
assert!(!mobile.result.passed);
assert!(result
.diagnostics
.iter()
.any(|message| message.starts_with("playtest mobile:")));
}
async fn run_stable_generic_viewport_fixture(
mobile_action_broken: bool,
) -> BrowserValidationResult {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use std::thread;
discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed");
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview");
let port = listener.local_addr().expect("preview address").port();
listener.set_nonblocking(true).expect("nonblocking preview");
let html = br#"<!doctype html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="data:"><title>Stable Generic Browser Fixture</title></head>
<body>
<main>Stable generic fixture</main>
<canvas id="stage" width="320" height="180" style="width:320px;max-width:100%;height:auto"></canvas>
<button type="button" data-playtest-id="start">Start</button>
<button type="button" data-playtest-id="primary-action">Collect</button>
<button type="button" data-playtest-id="restart">Restart</button>
<script id="playable-web-game-state" type="application/json">{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"ready","level":1,"score":0}</script>
<script>
(() => {
'use strict';
const stateNode = document.querySelector('#playable-web-game-state');
const state = JSON.parse(stateNode.textContent);
const context = document.querySelector('#stage').getContext('2d');
context.fillStyle = '#0f172a';
context.fillRect(0, 0, 320, 180);
context.fillStyle = '#22c55e';
context.fillRect(48, 48, 96, 64);
const publish = () => { stateNode.textContent = JSON.stringify(state); };
const advance = (mutation) => {
mutation();
state.sequence += 1;
publish();
};
document.querySelector('[data-playtest-id="start"]').addEventListener('click', (event) => {
if (event.isTrusted && state.phase === 'ready') {
advance(() => { state.phase = 'playing'; });
}
});
const primary = document.querySelector('[data-playtest-id="primary-action"]');
const restart = document.querySelector('[data-playtest-id="restart"]');
const mobileActionBroken = false;
window.addEventListener('pointerdown', (event) => {
if (event.isTrusted && event.target === primary && state.phase === 'playing') {
if (mobileActionBroken && window.innerWidth < 600) return;
advance(() => { state.score += 1; });
}
}, true);
window.addEventListener('click', (event) => {
if (!event.isTrusted) return;
if (event.target === primary) {
event.stopImmediatePropagation();
return;
}
if (event.target === restart) {
advance(() => {
state.phase = 'ready';
state.score = 0;
});
event.stopImmediatePropagation();
}
}, true);
})();
</script>
</body>
</html>"#;
let html = String::from_utf8_lossy(html)
.replace(
"const mobileActionBroken = false;",
if mobile_action_broken {
"const mobileActionBroken = true;"
} else {
"const mobileActionBroken = false;"
},
)
.into_bytes();
let (stop_tx, stop_rx) = mpsc::channel();
let server = thread::spawn(move || {
while stop_rx.try_recv().is_err() {
match listener.accept() {
Ok((mut stream, _)) => {
let mut request = [0_u8; 2048];
let _ = stream.read(&mut request);
let headers = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
html.len()
);
let _ = stream.write_all(headers.as_bytes());
let _ = stream.write_all(&html);
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
}
Err(error) => panic!("preview accept failed: {error}"),
}
}
});
let evidence = tempfile::tempdir().expect("evidence tempdir");
let validation = validate_local_preview_in_browser(BrowserValidationInput {
url: format!("http://127.0.0.1:{port}/"),
viewports: REQUIRED_VIEWPORTS.to_vec(),
expected_text: vec!["Stable generic fixture".to_string()],
settle_ms: 100,
fail_on_console_error: true,
playtest_scenario: Some(BrowserPlaytestScenario::GenericV1),
evidence_root: evidence.path().join("evidence"),
})
.await;
let _ = stop_tx.send(());
server.join().expect("preview server");
let result = validation.expect("real stable generic browser validation");
let persisted: serde_json::Value =
serde_json::from_slice(&fs::read(&result.evidence.report_path).unwrap()).unwrap();
assert_eq!(persisted["viewportPlaytests"].as_array().unwrap().len(), 2);
result
}
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_generic_playtest_accepts_click_listener_registered_during_pointerdown() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use std::thread;
discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed");
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview");
let port = listener.local_addr().expect("preview address").port();
listener.set_nonblocking(true).expect("nonblocking preview");
let html = br#"<!doctype html>
<html>
<head><meta charset="utf-8"><link rel="icon" href="data:"><title>Late Listener Generic Browser Fixture</title></head>
<body>
<main>Late listener generic fixture</main>
<canvas id="stage" width="320" height="180" style="width:320px;max-width:100%;height:auto"></canvas>
<button type="button" data-playtest-id="start">Start</button>
<button type="button" data-playtest-id="primary-action">Collect</button>
<button type="button" data-playtest-id="restart">Restart</button>
<script id="playable-web-game-state" type="application/json">{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"ready","level":1,"score":0}</script>
<script>
(() => {
'use strict';
const stateNode = document.querySelector('#playable-web-game-state');
const state = JSON.parse(stateNode.textContent);
const context = document.querySelector('#stage').getContext('2d');
context.fillStyle = '#0f172a';
context.fillRect(0, 0, 320, 180);
context.fillStyle = '#38bdf8';
context.fillRect(48, 48, 96, 64);
const publish = () => { stateNode.textContent = JSON.stringify(state); };
const advance = (mutation) => {
mutation();
state.sequence += 1;
publish();
};
document.querySelector('[data-playtest-id="start"]').addEventListener('click', (event) => {
if (event.isTrusted && state.phase === 'ready') {
advance(() => { state.phase = 'playing'; });
}
});
const primary = document.querySelector('[data-playtest-id="primary-action"]');
window.addEventListener('pointerdown', (event) => {
if (!event.isTrusted || event.target !== primary || state.phase !== 'playing') return;
window.addEventListener('click', (clickEvent) => {
if (clickEvent.isTrusted && clickEvent.target === primary && state.phase === 'playing') {
advance(() => { state.score += 1; });
}
}, { once: true });
}, true);
document.querySelector('[data-playtest-id="restart"]').addEventListener('click', (event) => {
if (event.isTrusted) {
advance(() => {
state.phase = 'ready';
state.score = 0;
});
event.stopImmediatePropagation();
}
});
})();
</script>
</body>
</html>"#;
let (stop_tx, stop_rx) = mpsc::channel();
let server = thread::spawn(move || {
while stop_rx.try_recv().is_err() {
match listener.accept() {
Ok((mut stream, _)) => {
let mut request = [0_u8; 2048];
let _ = stream.read(&mut request);
let headers = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
html.len()
);
let _ = stream.write_all(headers.as_bytes());
let _ = stream.write_all(html);
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
}
Err(error) => panic!("preview accept failed: {error}"),
}
}
});
let evidence = tempfile::tempdir().expect("evidence tempdir");
let validation = validate_local_preview_in_browser(BrowserValidationInput {
url: format!("http://127.0.0.1:{port}/"),
viewports: REQUIRED_VIEWPORTS.to_vec(),
expected_text: vec!["Late listener generic fixture".to_string()],
settle_ms: 100,
fail_on_console_error: true,
playtest_scenario: Some(BrowserPlaytestScenario::GenericV1),
evidence_root: evidence.path().join("evidence"),
})
.await;
let _ = stop_tx.send(());
server.join().expect("preview server");
let result = validation.expect("real late-listener generic browser validation");
assert!(
result.passed,
"diagnostics={:#?}\nviewports={:#?}",
result.diagnostics, result.viewport_results
);
let playtest = result.playtest.expect("generic playtest result");
assert!(playtest.passed, "{:#?}", playtest.diagnostics);
assert_eq!(playtest.initial_sequence, Some(0));
assert_eq!(playtest.final_sequence, Some(3));
assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Ready));
assert!(playtest.assertions.iter().all(|assertion| assertion.passed));
}
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_generic_playtest_rejects_timer_deferred_action_progress() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use std::thread;
discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed");
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview");
let port = listener.local_addr().expect("preview address").port();
listener.set_nonblocking(true).expect("nonblocking preview");
let html = br#"<!doctype html>
<html>
<head><meta charset="utf-8"><link rel="icon" href="data:"><title>Deferred Generic Browser Fixture</title></head>
<body>
<main>Deferred generic fixture</main>
<canvas id="stage" width="320" height="180" style="width:320px;max-width:100%;height:auto"></canvas>
<button type="button" data-playtest-id="start">Start</button>
<button type="button" data-playtest-id="primary-action">Collect later</button>
<button type="button" data-playtest-id="restart">Restart</button>
<script id="playable-web-game-state" type="application/json">{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"ready","level":1,"score":0}</script>
<script>
(() => {
'use strict';
const stateNode = document.querySelector('#playable-web-game-state');
const state = JSON.parse(stateNode.textContent);
const context = document.querySelector('#stage').getContext('2d');
context.fillStyle = '#0f172a';
context.fillRect(0, 0, 320, 180);
context.fillStyle = '#a855f7';
context.fillRect(48, 48, 96, 64);
const publish = () => { stateNode.textContent = JSON.stringify(state); };
const advance = (mutation) => {
mutation();
state.sequence += 1;
publish();
};
document.querySelector('[data-playtest-id="start"]').addEventListener('click', (event) => {
if (event.isTrusted && state.phase === 'ready') {
advance(() => { state.phase = 'playing'; });
}
});
document.querySelector('[data-playtest-id="primary-action"]').addEventListener('click', (event) => {
if (event.isTrusted && state.phase === 'playing') {
setTimeout(() => advance(() => { state.score += 1; }), 0);
}
});
document.querySelector('[data-playtest-id="restart"]').addEventListener('click', (event) => {
if (event.isTrusted) {
advance(() => {
state.phase = 'ready';
state.score = 0;
});
}
});
})();
</script>
</body>
</html>"#;
let (stop_tx, stop_rx) = mpsc::channel();
let server = thread::spawn(move || {
while stop_rx.try_recv().is_err() {
match listener.accept() {
Ok((mut stream, _)) => {
let mut request = [0_u8; 2048];
let _ = stream.read(&mut request);
let headers = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
html.len()
);
let _ = stream.write_all(headers.as_bytes());
let _ = stream.write_all(html);
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
}
Err(error) => panic!("preview accept failed: {error}"),
}
}
});
let evidence = tempfile::tempdir().expect("evidence tempdir");
let validation = validate_local_preview_in_browser(BrowserValidationInput {
url: format!("http://127.0.0.1:{port}/"),
viewports: REQUIRED_VIEWPORTS.to_vec(),
expected_text: vec!["Deferred generic fixture".to_string()],
settle_ms: 100,
fail_on_console_error: true,
playtest_scenario: Some(BrowserPlaytestScenario::GenericV1),
evidence_root: evidence.path().join("evidence"),
})
.await;
let _ = stop_tx.send(());
server.join().expect("preview server");
let result = validation.expect("real deferred generic browser validation");
assert!(!result.passed, "deferred action must fail validation");
let playtest = result.playtest.expect("generic playtest result");
assert!(!playtest.passed, "{:#?}", playtest.diagnostics);
assert!(
playtest
.diagnostics
.iter()
.any(|message| message.contains("RAF/timer")),
"diagnostics={:#?}",
playtest.diagnostics
);
}
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_generic_playtest_binds_tetris_actions_to_gameplay_state() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use std::thread;
discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed");
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview");
let port = listener.local_addr().expect("preview address").port();
listener.set_nonblocking(true).expect("nonblocking preview");
let html = br#"<!doctype html>
<html>
<head><meta charset="utf-8"><link rel="icon" href="data:"><title>Tetris Behavior Browser Fixture</title></head>
<body>
<main>Tetris behavior fixture</main>
<canvas id="stage" width="320" height="180" style="width:320px;max-width:100%;height:auto"></canvas>
<button type="button" data-playtest-id="start">Start</button>
<button type="button" data-playtest-id="primary-action">Rotate</button>
<button type="button" data-playtest-id="restart">Restart</button>
<script id="playable-web-game-state" type="application/json">{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"ready","level":1,"gameplay":{"kind":"tetris","activePieceId":"piece-1","rotation":0,"row":0,"lockedPieces":0,"lineClearChecks":0,"clearedLines":0,"occupiedCells":0}}</script>
<script>
(() => {
'use strict';
const stateNode = document.querySelector('#playable-web-game-state');
const state = JSON.parse(stateNode.textContent);
const context = document.querySelector('#stage').getContext('2d');
let rotated = false;
const render = () => {
context.fillStyle = '#0f172a';
context.fillRect(0, 0, 320, 180);
context.fillStyle = state.gameplay.rotation % 2 === 0 ? '#38bdf8' : '#f97316';
context.fillRect(120, 8 + state.gameplay.row * 5, 48, 24);
if (state.gameplay.occupiedCells > 0) {
context.fillStyle = '#22c55e';
context.fillRect(96, 152, 96, 16);
}
};
const publish = () => {
stateNode.textContent = JSON.stringify(state);
render();
};
const advance = (mutation) => {
mutation();
state.sequence += 1;
publish();
};
render();
document.querySelector('[data-playtest-id="start"]').addEventListener('click', (event) => {
if (event.isTrusted && state.phase === 'ready') {
advance(() => { state.phase = 'playing'; });
}
});
document.querySelector('[data-playtest-id="primary-action"]').addEventListener('click', (event) => {
if (event.isTrusted && state.phase === 'playing') {
advance(() => {
state.gameplay.rotation = (state.gameplay.rotation + 1) % 4;
rotated = true;
});
queueMicrotask(() => {
globalThis.__genarrativeGenericActionSequenceProbe = {
status: 'completed',
beforeSequence: 999,
afterSequence: 999,
beforeGameplay: null,
afterGameplay: null,
};
});
}
});
document.querySelector('[data-playtest-id="restart"]').addEventListener('click', (event) => {
if (event.isTrusted) {
advance(() => {
state.phase = 'ready';
state.gameplay = {kind:'tetris',activePieceId:'piece-reset',rotation:0,row:0,lockedPieces:0,lineClearChecks:0,clearedLines:0,occupiedCells:0};
rotated = false;
});
}
});
setInterval(() => {
if (state.phase !== 'playing') return;
if (!rotated && state.gameplay.row >= 15) return;
if (state.gameplay.row < 18) {
advance(() => { state.gameplay.row += 1; });
return;
}
advance(() => {
state.gameplay.lockedPieces += 1;
state.gameplay.lineClearChecks += 1;
state.gameplay.occupiedCells = 4;
state.gameplay.activePieceId = 'piece-2';
state.gameplay.rotation = 0;
state.gameplay.row = 0;
rotated = false;
});
}, 100);
})();
</script>
</body>
</html>"#;
let (stop_tx, stop_rx) = mpsc::channel();
let server = thread::spawn(move || {
while stop_rx.try_recv().is_err() {
match listener.accept() {
Ok((mut stream, _)) => {
let mut request = [0_u8; 2048];
let _ = stream.read(&mut request);
let headers = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
html.len()
);
let _ = stream.write_all(headers.as_bytes());
let _ = stream.write_all(html);
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
}
Err(error) => panic!("preview accept failed: {error}"),
}
}
});
let evidence = tempfile::tempdir().expect("evidence tempdir");
let validation = validate_local_preview_in_browser(BrowserValidationInput {
url: format!("http://127.0.0.1:{port}/"),
viewports: REQUIRED_VIEWPORTS.to_vec(),
expected_text: vec!["Tetris behavior fixture".to_string()],
settle_ms: 100,
fail_on_console_error: true,
playtest_scenario: Some(BrowserPlaytestScenario::TetrisV1),
evidence_root: evidence.path().join("evidence"),
})
.await;
let _ = stop_tx.send(());
server.join().expect("preview server");
let result = validation.expect("real tetris behavior browser validation");
assert!(
result.passed,
"diagnostics={:#?}\nplaytest={:#?}",
result.diagnostics, result.playtest
);
let playtest = result.playtest.expect("tetris playtest result");
assert!(playtest.passed, "{:#?}", playtest.diagnostics);
assert!(playtest.assertions.iter().all(|assertion| assertion.passed));
}
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_tetris_rejects_lock_that_precedes_the_action_probe_baseline() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use std::thread;
discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed");
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview");
let port = listener.local_addr().expect("preview address").port();
listener.set_nonblocking(true).expect("nonblocking preview");
let html = br#"<!doctype html>
<html>
<head><meta charset="utf-8"><link rel="icon" href="data:"><title>Pre-action Tetris Lock Fixture</title></head>
<body>
<main>Pre-action Tetris lock fixture</main>
<canvas id="stage" width="320" height="180" style="width:320px;max-width:100%;height:auto"></canvas>
<button type="button" data-playtest-id="start">Start</button>
<button type="button" data-playtest-id="primary-action">Rotate</button>
<button type="button" data-playtest-id="restart">Restart</button>
<script id="playable-web-game-state" type="application/json">{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"ready","level":1,"gameplay":{"kind":"tetris","activePieceId":"piece-1","rotation":0,"row":0,"lockedPieces":0,"lineClearChecks":0,"clearedLines":0,"occupiedCells":0}}</script>
<script>
(() => {
'use strict';
const stateNode = document.querySelector('#playable-web-game-state');
const primary = document.querySelector('[data-playtest-id="primary-action"]');
const state = JSON.parse(stateNode.textContent);
const context = document.querySelector('#stage').getContext('2d');
let interveningLockDone = false;
const render = () => {
context.fillStyle = '#0f172a';
context.fillRect(0, 0, 320, 180);
context.fillStyle = '#eab308';
context.fillRect(120, 8 + state.gameplay.row * 5, 48, 24);
};
const publish = () => {
stateNode.textContent = JSON.stringify(state);
render();
};
const advance = (mutation) => {
mutation();
state.sequence += 1;
publish();
};
render();
document.querySelector('[data-playtest-id="start"]').addEventListener('click', (event) => {
if (event.isTrusted && state.phase === 'ready') {
advance(() => { state.phase = 'playing'; });
}
});
document.addEventListener('click', (event) => {
if (event.isTrusted && event.target === primary && !interveningLockDone) {
advance(() => {
state.gameplay.lockedPieces = 1;
state.gameplay.lineClearChecks = 1;
state.gameplay.occupiedCells = 4;
state.gameplay.activePieceId = 'piece-2';
state.gameplay.rotation = 0;
state.gameplay.row = 0;
interveningLockDone = true;
});
}
}, true);
primary.addEventListener('click', (event) => {
if (event.isTrusted && state.phase === 'playing') {
advance(() => {
state.gameplay.rotation = (state.gameplay.rotation + 1) % 4;
});
}
});
document.querySelector('[data-playtest-id="restart"]').addEventListener('click', (event) => {
if (event.isTrusted) {
advance(() => {
state.phase = 'ready';
state.gameplay = {kind:'tetris',activePieceId:'piece-reset',rotation:0,row:0,lockedPieces:0,lineClearChecks:0,clearedLines:0,occupiedCells:0};
});
}
});
setInterval(() => {
if (state.phase === 'playing' && !interveningLockDone && state.gameplay.row < 10) {
advance(() => { state.gameplay.row += 1; });
}
}, 100);
})();
</script>
</body>
</html>"#;
let (stop_tx, stop_rx) = mpsc::channel();
let server = thread::spawn(move || {
while stop_rx.try_recv().is_err() {
match listener.accept() {
Ok((mut stream, _)) => {
let mut request = [0_u8; 2048];
let _ = stream.read(&mut request);
let headers = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
html.len()
);
let _ = stream.write_all(headers.as_bytes());
let _ = stream.write_all(html);
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
}
Err(error) => panic!("preview accept failed: {error}"),
}
}
});
let evidence = tempfile::tempdir().expect("evidence tempdir");
let validation = validate_local_preview_in_browser(BrowserValidationInput {
url: format!("http://127.0.0.1:{port}/"),
viewports: REQUIRED_VIEWPORTS.to_vec(),
expected_text: vec!["Pre-action Tetris lock fixture".to_string()],
settle_ms: 100,
fail_on_console_error: true,
playtest_scenario: Some(BrowserPlaytestScenario::TetrisV1),
evidence_root: evidence.path().join("evidence"),
})
.await;
let _ = stop_tx.send(());
server.join().expect("preview server");
let result = validation.expect("real pre-action-lock browser validation");
assert!(
!result.passed,
"a lock before the probe baseline must not prove post-action locking"
);
let playtest = result.playtest.expect("tetris playtest result");
assert!(!playtest.passed, "{:#?}", playtest.assertions);
assert!(
playtest
.diagnostics
.iter()
.any(|diagnostic| diagnostic.contains("activePieceId")),
"unexpected diagnostics: {:#?}",
playtest.diagnostics
);
}
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_generic_playtest_accepts_first_lost_when_retry_proves_non_loss_progression() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use std::thread;
discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed");
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview");
let port = listener.local_addr().expect("preview address").port();
listener.set_nonblocking(true).expect("nonblocking preview");
let html = br#"<!doctype html>
<html>
<head><meta charset="utf-8"><link rel="icon" href="data:"><title>Recoverable Lost Generic Browser Fixture</title></head>
<body>
<main>Recoverable-lost generic fixture</main>
<canvas id="stage" width="320" height="180" style="width:320px;max-width:100%;height:auto"></canvas>
<button type="button" data-playtest-id="start">Start</button>
<button type="button" data-playtest-id="primary-action">Dash</button>
<button type="button" data-playtest-id="restart">Restart</button>
<script id="playable-web-game-state" type="application/json">{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"ready","level":1}</script>
<script>
(() => {
'use strict';
const stateNode = document.querySelector('#playable-web-game-state');
const state = JSON.parse(stateNode.textContent);
const context = document.querySelector('#stage').getContext('2d');
context.fillStyle = '#0f172a';
context.fillRect(0, 0, 320, 180);
context.fillStyle = '#f59e0b';
context.fillRect(48, 48, 96, 64);
let attempt = 0;
const publish = () => { stateNode.textContent = JSON.stringify(state); };
const advance = (phase) => {
state.phase = phase;
state.sequence += 1;
publish();
};
document.querySelector('[data-playtest-id="start"]').addEventListener('click', (event) => {
if (event.isTrusted && state.phase === 'ready') advance('playing');
});
document.querySelector('[data-playtest-id="primary-action"]').addEventListener('click', (event) => {
if (event.isTrusted && state.phase === 'playing') {
attempt += 1;
advance(attempt === 1 ? 'lost' : 'playing');
}
});
document.querySelector('[data-playtest-id="restart"]').addEventListener('click', (event) => {
if (event.isTrusted) advance('ready');
});
})();
</script>
</body>
</html>"#;
let (stop_tx, stop_rx) = mpsc::channel();
let server = thread::spawn(move || {
while stop_rx.try_recv().is_err() {
match listener.accept() {
Ok((mut stream, _)) => {
let mut request = [0_u8; 2048];
let _ = stream.read(&mut request);
let headers = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
html.len()
);
let _ = stream.write_all(headers.as_bytes());
let _ = stream.write_all(html);
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
}
Err(error) => panic!("preview accept failed: {error}"),
}
}
});
let evidence = tempfile::tempdir().expect("evidence tempdir");
let validation = validate_local_preview_in_browser(BrowserValidationInput {
url: format!("http://127.0.0.1:{port}/"),
viewports: REQUIRED_VIEWPORTS.to_vec(),
expected_text: vec!["Recoverable-lost generic fixture".to_string()],
settle_ms: 100,
fail_on_console_error: true,
playtest_scenario: Some(BrowserPlaytestScenario::GenericV1),
evidence_root: evidence.path().join("evidence"),
})
.await;
let _ = stop_tx.send(());
server.join().expect("preview server");
let result = validation.expect("real recoverable-lost generic browser validation");
assert!(
result.passed,
"diagnostics={:#?}\nviewports={:#?}",
result.diagnostics, result.viewport_results
);
let playtest = result.playtest.expect("generic playtest result");
assert!(playtest.passed, "{:#?}", playtest.diagnostics);
assert_eq!(playtest.initial_sequence, Some(0));
assert_eq!(playtest.final_sequence, Some(5));
assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Playing));
assert!(playtest.assertions.iter().all(|assertion| assertion.passed));
}
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_generic_playtest_rejects_fixed_lost_on_both_controlled_attempts() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use std::thread;
discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed");
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview");
let port = listener.local_addr().expect("preview address").port();
listener.set_nonblocking(true).expect("nonblocking preview");
let html = br#"<!doctype html>
<html>
<head><meta charset="utf-8"><link rel="icon" href="data:"><title>Fixed Lost Generic Browser Fixture</title></head>
<body>
<main>Fixed-lost generic fixture</main>
<canvas id="stage" width="320" height="180" style="width:320px;max-width:100%;height:auto"></canvas>
<button type="button" data-playtest-id="start">Start</button>
<button type="button" data-playtest-id="primary-action">Dash</button>
<button type="button" data-playtest-id="restart">Restart</button>
<script id="playable-web-game-state" type="application/json">{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"ready","level":1}</script>
<script>
(() => {
'use strict';
const stateNode = document.querySelector('#playable-web-game-state');
const state = JSON.parse(stateNode.textContent);
const context = document.querySelector('#stage').getContext('2d');
context.fillStyle = '#0f172a';
context.fillRect(0, 0, 320, 180);
context.fillStyle = '#dc2626';
context.fillRect(48, 48, 96, 64);
const publish = () => { stateNode.textContent = JSON.stringify(state); };
const advance = (phase) => {
state.phase = phase;
state.sequence += 1;
publish();
};
document.querySelector('[data-playtest-id="start"]').addEventListener('click', (event) => {
if (event.isTrusted && state.phase === 'ready') advance('playing');
});
document.querySelector('[data-playtest-id="primary-action"]').addEventListener('click', (event) => {
if (event.isTrusted && state.phase === 'playing') advance('lost');
});
document.querySelector('[data-playtest-id="restart"]').addEventListener('click', (event) => {
if (event.isTrusted) advance('ready');
});
})();
</script>
</body>
</html>"#;
let (stop_tx, stop_rx) = mpsc::channel();
let server = thread::spawn(move || {
while stop_rx.try_recv().is_err() {
match listener.accept() {
Ok((mut stream, _)) => {
let mut request = [0_u8; 2048];
let _ = stream.read(&mut request);
let headers = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
html.len()
);
let _ = stream.write_all(headers.as_bytes());
let _ = stream.write_all(html);
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
}
Err(error) => panic!("preview accept failed: {error}"),
}
}
});
let evidence = tempfile::tempdir().expect("evidence tempdir");
let validation = validate_local_preview_in_browser(BrowserValidationInput {
url: format!("http://127.0.0.1:{port}/"),
viewports: REQUIRED_VIEWPORTS.to_vec(),
expected_text: vec!["Fixed-lost generic fixture".to_string()],
settle_ms: 100,
fail_on_console_error: true,
playtest_scenario: Some(BrowserPlaytestScenario::GenericV1),
evidence_root: evidence.path().join("evidence"),
})
.await;
let _ = stop_tx.send(());
server.join().expect("preview server");
let result = validation.expect("real fixed-lost generic browser validation");
assert!(!result.passed, "fixed lost must fail browser validation");
let playtest = result.playtest.expect("generic playtest result");
assert!(!playtest.passed, "{:#?}", playtest.assertions);
assert_eq!(playtest.initial_sequence, Some(0));
assert_eq!(playtest.final_sequence, Some(5));
assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Lost));
assert!(
playtest
.diagnostics
.iter()
.any(|diagnostic| diagnostic.contains("固定失败")),
"unexpected diagnostics: {:#?}",
playtest.diagnostics
);
assert_eq!(
playtest
.assertions
.iter()
.find(|assertion| assertion.name == "non-loss-progression-observed")
.map(|assertion| assertion.passed),
Some(false)
);
}
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_lane_defense_playtest() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use std::thread;
discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed");
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview");
let port = listener.local_addr().expect("preview address").port();
listener.set_nonblocking(true).expect("nonblocking preview");
let html = br#"<!doctype html>
<html>
<head><meta charset="utf-8"><link rel="icon" href="data:"><title>Lane Defense Browser Fixture</title></head>
<body>
<main>Lane defense fixture</main>
<canvas id="stage" width="640" height="320" style="width:640px;max-width:100%;height:auto"></canvas>
<div id="controls">
<button type="button" data-playtest-id="start">Start</button>
<button type="button" data-playtest-id="defender-option" hidden disabled>Archer</button>
<button type="button" data-playtest-id="lane-cell" hidden disabled>Lane 1</button>
<button type="button" data-playtest-id="speed-up" hidden disabled>Speed up</button>
<button type="button" data-playtest-id="next-level" hidden disabled>Next level</button>
<button type="button" data-playtest-id="restart" hidden disabled>Restart</button>
</div>
<script id="playable-web-game-state" type="application/json">{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"ready","level":1,"selectedDefenderId":null,"defenders":[],"enemies":[],"speedMultiplier":1}</script>
<script>
(() => {
'use strict';
const stateNode = document.querySelector('#playable-web-game-state');
const state = JSON.parse(stateNode.textContent);
const canvas = document.querySelector('#stage');
const context = canvas.getContext('2d');
const controls = Object.fromEntries(
Array.from(document.querySelectorAll('[data-playtest-id]')).map((control) => [
control.dataset.playtestId,
control,
]),
);
let battleGeneration = 0;
const setActive = (control, active) => {
control.hidden = !active;
control.disabled = !active;
control.setAttribute('aria-disabled', String(!active));
};
const render = () => {
context.fillStyle = '#0f172a';
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = '#2563eb';
context.fillRect(0, 80, canvas.width, 70);
context.fillStyle = '#0ea5e9';
context.fillRect(0, 170, canvas.width, 70);
for (const defender of state.defenders) {
context.fillStyle = '#22c55e';
context.fillRect(defender.position, 90, 36, 50);
}
for (const enemy of state.enemies) {
context.fillStyle = '#ef4444';
context.fillRect(enemy.position, 100, 34, 34);
context.fillStyle = '#f8fafc';
context.fillRect(enemy.position, 90, 34 * (enemy.health / enemy.maxHealth), 5);
}
context.fillStyle = '#f8fafc';
context.font = '20px sans-serif';
context.fillText(`Level ${state.level} ${state.phase}`, 16, 30);
};
const publish = () => {
stateNode.textContent = JSON.stringify(state);
setActive(controls.start, state.phase === 'ready');
setActive(
controls['defender-option'],
state.phase === 'playing' && state.selectedDefenderId === null && state.defenders.length === 0,
);
setActive(
controls['lane-cell'],
state.phase === 'playing' && state.selectedDefenderId !== null && state.defenders.length === 0,
);
setActive(
controls['speed-up'],
state.phase === 'playing' && state.defenders.length > 0 && state.speedMultiplier === 1,
);
setActive(controls['next-level'], state.phase === 'won');
setActive(controls.restart, state.phase === 'playing' && state.level > 1);
render();
};
const advance = (mutation) => {
mutation();
state.sequence += 1;
publish();
};
const onTrustedClick = (control, handler) => {
control.addEventListener('click', (event) => {
if (!event.isTrusted) return;
handler();
});
};
onTrustedClick(controls.start, () => {
if (state.phase !== 'ready') return;
advance(() => { state.phase = 'playing'; });
});
onTrustedClick(controls['defender-option'], () => {
if (state.phase !== 'playing' || state.selectedDefenderId !== null) return;
advance(() => { state.selectedDefenderId = 'archer'; });
});
onTrustedClick(controls['lane-cell'], () => {
if (state.selectedDefenderId !== 'archer' || state.defenders.length !== 0) return;
advance(() => {
state.defenders.push({ id: 'archer-1', position: 90 });
state.enemies.push({
id: 'enemy-1',
lane: 0,
position: 520,
health: 10,
maxHealth: 10,
});
state.selectedDefenderId = null;
});
});
onTrustedClick(controls['speed-up'], () => {
if (state.enemies.length === 0 || state.speedMultiplier !== 1) return;
const generation = ++battleGeneration;
advance(() => { state.speedMultiplier = 2; });
setTimeout(() => {
if (generation !== battleGeneration || state.enemies.length === 0) return;
advance(() => { state.enemies[0].position = 360; });
}, 120);
setTimeout(() => {
if (generation !== battleGeneration || state.enemies.length === 0) return;
advance(() => { state.enemies[0].health = 3; });
}, 240);
setTimeout(() => {
if (generation !== battleGeneration || state.enemies.length === 0) return;
advance(() => {
state.enemies = [];
state.phase = 'won';
});
}, 360);
});
onTrustedClick(controls['next-level'], () => {
if (state.phase !== 'won') return;
battleGeneration += 1;
advance(() => {
state.level += 1;
state.phase = 'playing';
state.selectedDefenderId = null;
state.defenders = [];
state.enemies = [];
state.speedMultiplier = 1;
});
});
onTrustedClick(controls.restart, () => {
if (state.phase !== 'playing' || state.level <= 1) return;
battleGeneration += 1;
advance(() => {
state.phase = 'ready';
state.selectedDefenderId = null;
state.defenders = [];
state.enemies = [];
state.speedMultiplier = 1;
});
});
publish();
})();
</script>
</body>
</html>"#;
let (stop_tx, stop_rx) = mpsc::channel();
let server = thread::spawn(move || {
while stop_rx.try_recv().is_err() {
match listener.accept() {
Ok((mut stream, _)) => {
let mut request = [0_u8; 2048];
let _ = stream.read(&mut request);
let headers = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
html.len()
);
let _ = stream.write_all(headers.as_bytes());
let _ = stream.write_all(html);
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
}
Err(error) => panic!("preview accept failed: {error}"),
}
}
});
let evidence = tempfile::tempdir().expect("evidence tempdir");
let validation = validate_local_preview_in_browser(BrowserValidationInput {
url: format!("http://127.0.0.1:{port}/"),
viewports: REQUIRED_VIEWPORTS.to_vec(),
expected_text: vec!["Lane defense fixture".to_string()],
settle_ms: 100,
fail_on_console_error: true,
playtest_scenario: Some(BrowserPlaytestScenario::LaneDefenseV1),
evidence_root: evidence.path().join("evidence"),
})
.await;
let _ = stop_tx.send(());
server.join().expect("preview server");
let result = validation.expect("real lane-defense browser validation");
assert!(result.passed, "{:#?}", result.diagnostics);
assert!(result.evidence.report_path.is_file());
assert!(result.viewport_results.iter().all(|viewport| {
viewport.passed
&& viewport
.canvases
.iter()
.any(|canvas| canvas.non_empty == Some(true))
}));
let playtest = result.playtest.expect("lane-defense playtest result");
assert!(playtest.passed, "{:#?}", playtest.diagnostics);
assert_eq!(playtest.scenario, BrowserPlaytestScenario::LaneDefenseV1);
assert_eq!(playtest.initial_sequence, Some(0));
assert_eq!(playtest.initial_phase, Some(BrowserPlaytestPhase::Ready));
assert_eq!(playtest.initial_level, Some(1));
assert_eq!(playtest.final_sequence, Some(9));
assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Ready));
assert_eq!(playtest.final_level, Some(2));
assert!(playtest.assertions.iter().all(|assertion| assertion.passed));
}
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_validation_smoke() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use std::thread;
discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed");
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview");
let port = listener.local_addr().expect("preview address").port();
listener.set_nonblocking(true).expect("nonblocking preview");
let mut png = Vec::new();
image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
2,
2,
image::Rgba([255, 96, 32, 255]),
))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.expect("encode browser fixture PNG");
let (stop_tx, stop_rx) = mpsc::channel();
let server = thread::spawn(move || {
let html = br#"<!doctype html><html><head><title>Browser Probe</title></head><body><main>Expected local preview</main><img id="side" src="/side-only.png" alt="side only"><canvas id="transparent" width="64" height="64" style="width:64px;height:64px"></canvas><canvas id="solid" width="64" height="64" style="width:64px;height:64px"></canvas><canvas id="drawn" width="64" height="64" style="width:64px;height:64px"></canvas><script>const solid=document.querySelector('#solid').getContext('2d');solid.fillStyle='#16a34a';solid.fillRect(0,0,64,64);const drawn=document.querySelector('#drawn').getContext('2d');drawn.fillStyle='#16a34a';drawn.fillRect(0,0,64,64);drawn.fillStyle='#2563eb';drawn.fillRect(0,0,32,64);const gameplayArt=new Image();gameplayArt.onload=()=>drawn.drawImage(gameplayArt,16,16,32,32);gameplayArt.src='/drawn.png';console.warn('probe warning');</script></body></html>"#;
while stop_rx.try_recv().is_err() {
match listener.accept() {
Ok((mut stream, _)) => {
let mut request = [0_u8; 2048];
let count = stream.read(&mut request).unwrap_or_default();
let request = String::from_utf8_lossy(&request[..count]);
let is_png = request.starts_with("GET /drawn.png ")
|| request.starts_with("GET /side-only.png ");
let body = if is_png {
png.as_slice()
} else {
html.as_slice()
};
let content_type = if is_png {
"image/png"
} else {
"text/html; charset=utf-8"
};
let headers = format!(
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = stream.write_all(headers.as_bytes());
let _ = stream.write_all(body);
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
}
Err(error) => panic!("preview accept failed: {error}"),
}
}
});
let evidence = tempfile::tempdir().expect("evidence tempdir");
let result = validate_local_preview_in_browser(BrowserValidationInput {
url: format!("http://127.0.0.1:{port}/"),
viewports: REQUIRED_VIEWPORTS.to_vec(),
expected_text: vec!["Expected local preview".to_string()],
settle_ms: 300,
fail_on_console_error: true,
playtest_scenario: None,
evidence_root: evidence.path().join("evidence"),
})
.await
.expect("real browser validation");
let _ = stop_tx.send(());
server.join().expect("preview server");
assert!(result.passed, "{:?}", result.diagnostics);
assert_eq!(result.viewport_results.len(), REQUIRED_VIEWPORTS.len());
assert!(result.evidence.report_path.is_file());
for (viewport_result, expected_viewport) in
result.viewport_results.iter().zip(REQUIRED_VIEWPORTS)
{
let (width, height, _) = expected_viewport.dimensions();
assert_eq!(viewport_result.viewport, expected_viewport);
assert_eq!(
(viewport_result.width, viewport_result.height),
(width, height)
);
assert!(viewport_result.screenshot_path.is_file());
assert!(viewport_result.expected_text[0].found);
assert!(viewport_result
.console_warnings
.iter()
.any(|warning| warning.text.contains("probe warning")));
assert_eq!(viewport_result.canvases.len(), 3);
assert_eq!(viewport_result.canvases[0].non_empty_pixel_count, 0);
assert_eq!(viewport_result.canvases[0].non_empty, Some(false));
assert_eq!(
viewport_result.canvases[1].non_empty_pixel_count,
viewport_result.canvases[1].sample_count
);
assert_eq!(viewport_result.canvases[1].non_empty, Some(false));
assert_eq!(viewport_result.canvases[2].non_empty, Some(true));
let rendered_diagnostic = viewport_result
.diagnostics
.iter()
.find(|diagnostic| diagnostic.starts_with("[advisory] rendered-local-images:"))
.expect("rendered image observation");
assert!(rendered_diagnostic.contains("/drawn.png"));
assert!(!rendered_diagnostic.contains("/side-only.png"));
}
}