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
## 背景 净月潭案例(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
2609 lines
100 KiB
Rust
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, ¤t), (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(®ressed);
|
|
assert!(!regressed_progress.completed(®ressed));
|
|
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"));
|
|
}
|
|
}
|