Refine school script diagnostics and document-state capture
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
# TPSchedule
|
||||
|
||||
当前Android版本0.7.1+19:学校浏览器启用宽视口和认证Cookie配置,修复本科认证跳转期间停止读取的问题,补齐脚本/CSS/嵌入页面失败诊断和Chromium版本显示。详见[0.7.1验证记录](docs/tpschedule-0.7.1-validation.md)。真实学校页面仍需本人手机复测。
|
||||
当前Android版本0.7.2+20:细化教务页面脚本异常、基础库和样式表状态日志,导出头部显示当前版本及时间。用户0.7.1日志显示认证已成功、随后脚本报错;具体根因仍需新日志确认,本版不能认定已解决组件缺失。详见[0.7.2验证记录](docs/tpschedule-0.7.2-validation.md)。
|
||||
|
||||
0.7.0补充:考试页查询类型与卡片保持16像素间距;反馈问题和建议可自选附带最近10分钟的登录/读取/提醒诊断日志。默认不发送,不含密码、Cookie或课表内容,单独作为邮件文本附件。服务端需按[日志附件更新说明](server/feedback/diagnostic-log-update.md)手动更新;真实学校登录及SMTP未由本次离线测试代替验证。玻璃取样偏移仍在调查,未纳入本次修复结论。
|
||||
|
||||
学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.1+19,Android 7.0 及以上。
|
||||
学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.2+20,Android 7.0 及以上。
|
||||
|
||||
使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Android 首版验证记录
|
||||
|
||||
当前浏览器兼容性修复版为0.7.1+19,验证见 [0.7.1记录](tpschedule-0.7.1-validation.md)。本科接入记录见 [0.7.0记录](tpschedule-0.7.0-validation.md)。下文为首版历史记录。
|
||||
当前浏览器诊断细化版为0.7.2+20,验证见 [0.7.2记录](tpschedule-0.7.2-validation.md)。本科接入记录见 [0.7.0记录](tpschedule-0.7.0-validation.md)。下文为首版历史记录。
|
||||
|
||||
日期:2026-10-13。测试环境:Flutter 3.47.7 / Dart 3.13.5 / JDK 17;Android 16 API 36,Pixel 7 模拟器,1080×2400。首版版本 0.1.0+1,包 ID com.example.hepan_everyday,最低 Android 7.0(API 24)。
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# 学校接入状态 · TPSchedule 0.7.1
|
||||
# 学校接入状态 · TPSchedule 0.7.2
|
||||
|
||||
0.7.1真机新日志确认本科认证成功,进入教务页面后有重复脚本错误。0.7.2细化现有日志,记录固定错误类型、已知基础库与样式状态及导出时间;尚未确定实际组件缺失的根因。详见 [0.7.2记录](tpschedule-0.7.2-validation.md)。
|
||||
|
||||
0.7.1补充学校浏览器宽视口、第三方Cookie和资源错误诊断,修复本科认证跳转导致读取永久中断的竞态。255项单元测试及Android16原生浏览器测试通过。用户截图中的样式与组件缺失尚需真机复测,不能仅凭测试认定已解决,详见 [0.7.1验证记录](tpschedule-0.7.1-validation.md)。
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# 审查问题处理
|
||||
|
||||
Important已修复:两所学校页面完成回调传入代次与生命周期有效性检查,异步URL读取及脚本返回后复核;保持同URL并让代次失效的回归测试观察到RED→GREEN。Minor已修复:beangle=null不再判断为库已加载,现有Node脚本测试覆盖null值。未要求再次审查;完整测试、静态检查与原生验证由主实现者完成并记录。以下为修复前独立审查及当时处理计划。
|
||||
|
||||
# Browser diagnostics 0.7.2 review
|
||||
|
||||
Reviewed the uncommitted mobile changes against `a4c2df838efe01f39fb27cc6062544a476d86949`, including the new diagnostics implementation, Dart tests, JavaScript guard test, and Android fixture. No original user logs or key files were opened. This is a read-only source review; no full tests were repeated and no application source was changed.
|
||||
|
||||
## Strengths
|
||||
|
||||
- Console messages become fixed category labels. The snapshot decoder exports only bounded integer counts and explicitly selected booleans, discarding unknown fields.
|
||||
- The JavaScript reads neither page text nor resource URLs, cookies, storage, forms, or HTML. Password-page and URL guards fail closed; inspection exceptions are swallowed.
|
||||
- Diagnostic capture is launched independently of the login/import callback and changes no navigation policy, import parser, or school security settings.
|
||||
- Export headers identify app version/build and the UTC export time. The fixture checks real WebView behavior while asserting private fixture text is absent from exported diagnostics.
|
||||
|
||||
## Issues
|
||||
|
||||
### Critical
|
||||
|
||||
None found.
|
||||
|
||||
### Important
|
||||
|
||||
1. **URL equality cannot reject a snapshot from a replaced document with the same URL.** `mobile/lib/features/school/school_browser_diagnostics.dart:110-118`; callback sites `mobile/lib/features/school/school_session.dart:112-114` and `mobile/lib/features/school/undergraduate/session.dart:142-144`.
|
||||
|
||||
Capture awaits both native URL reads and JavaScript execution. If a portal document reloads at the same URL while one of these operations is pending, all three URL comparisons still pass. In particular, JavaScript may return the old document's counts, `onPageStarted` can advance the session document generation, and the final `currentUrl()` can still equal the original URL. Those old counts are then appended after the new navigation. This violates the requested different-document guard and can make the diagnostic evidence misleading even though it does not change imported data.
|
||||
|
||||
Pass a captured document generation plus a current-generation predicate (or equivalent) into diagnostics, and recheck after awaits and immediately before recording. The session already maintains `_pageGeneration` / `_document`. Include lifecycle validity where relevant. Add a focused asynchronous test that holds the JavaScript result, advances the document generation while keeping the URL unchanged, then verifies that no events are recorded. Existing tests exercise a different URL but do not cover this case.
|
||||
|
||||
### Minor
|
||||
|
||||
1. **A null Beangle global is reported as a loaded library.** `mobile/lib/features/school/school_browser_diagnostics.dart:40`.
|
||||
|
||||
JavaScript reports `typeof null === 'object'`, so `window.beangle = null` produces `beangle: true`. Require a non-null value in addition to the object type and cover the null case in the JavaScript test. This is a small diagnostic accuracy issue, not a login failure.
|
||||
|
||||
## Declined to judge
|
||||
|
||||
- The cause and resolution of the authenticated portal's styling/script failure: real affected-page evidence is absent from this change, and this release only refines diagnostic evidence.
|
||||
- Whether the queued complete Dart suite or native fixture passes: the coordinator is running them; this review makes no independent runtime success claim.
|
||||
- Untracked output artifacts: excluded from application-source review and not opened because they could include original user material.
|
||||
|
||||
## Assessment
|
||||
|
||||
**Ready to merge: With fixes.** Privacy boundaries and isolation from login/import behavior are sound in the inspected code. Address the document-generation gap before claiming that snapshots reject replaced documents; do not describe this diagnostic refinement as a fix for the portal rendering problem.
|
||||
|
||||
## Coordinator disposition
|
||||
|
||||
The coordinator accepted the Important finding and plans to pass an `isCurrent` predicate from each native page-finished callback: graduate capture will use `_pageGeneration` and route/activity guards, and undergraduate capture will use `_generation`, `_document`, and `_valid`. The predicate will be checked around asynchronous native capture. A focused regression will keep the URL unchanged while invalidating the generation during a pending JavaScript result. This is the planned disposition, not an independently verified fix; no second review was requested.
|
||||
@@ -0,0 +1,35 @@
|
||||
# TPSchedule 0.7.2+20:教务页面脚本诊断细化
|
||||
|
||||
2026-10-15,工程F:/program/app。继续已获用户确认的学校WebView兼容性诊断,范围为细化现有日志,不切换内核、不改变学校认证及导入流程。
|
||||
|
||||
## 新证据与结论边界
|
||||
|
||||
本次读取用户Downloads中的0.7.1日志,时间为北京时间12:36–12:37。浏览器配置完成,第二次启动记录Chromium156.0.8078.4。身份认证返回本科系统后,school_evidence.authenticated=true;随后多次school_console_error.code=script_error。身份认证阶段有图片HTTPS404。没有证据把该图片404认定为教务页面整体缺少组件的原因,也没有证据认定手机缺少Chromium内核。
|
||||
|
||||
0.7.1把所有未分类控制台错误压成script_error,丢失了下一步定位所需的异常类型。因此本版修复的是诊断过粗,尚未修复已证实的登录后页面资源根因。
|
||||
|
||||
公开入口只读请求对Chrome和WebView两类UA都返回202及脚本页面。另用Android16原生WebView正常进入学校身份认证页,观察到样式表和jQuery存在。该临时观察没有使用账号、未登录、未读取课表;不能替代用户手机登录后的验证,也不能据此宣称防护页面导致此次故障。临时测试已移除,原始附件不进入仓库和源码包。
|
||||
|
||||
## 改动
|
||||
|
||||
- 控制台新增固定错误分类:jQuery、beangle、require未定义,及ReferenceError、SyntaxError、TypeError、SecurityError、EvalError、RangeError。只存固定类别,保留原有混合内容、CSP、跨域及资源拒绝分类;不存原始报错文字。
|
||||
- 两所学校原生页面加载完成时,异步采集脚本标签数、样式表数量、样式链接数量及具备sheet对象的链接数。本科额外记录已知基础库是否存在;统计性能记录中脚本/链接响应202的数量。202本身不代表故障,计数仅供后续结合其他事件分析。
|
||||
- 只读页面状态,不改变DOM、发起额外资源请求或注入学校业务功能;跳转或出现密码框时跳过。脚本不读取正文、表单内容、Cookie、存储或资源URL。返回值严格校验类型及上限,诊断失败不影响登录读取。
|
||||
- 导出头部新增当前应用版本、构建号和UTC导出时间,便于分辨旧附件。最近10分钟及用户自选附带规则不变;服务端协议不变,无需为本版重新更新服务器。
|
||||
|
||||
## 验证
|
||||
|
||||
- 固定分类及受限返回值回归测试观察到缺能力时失败、实现后通过。完整Flutter套件258项通过,静态检查无问题。
|
||||
- 新Node脚本测试验证实际脚本计数、基础库状态、重定向与密码框保护,并把Cookie、正文和资源URL设为读取即抛错,仍通过。
|
||||
- Android16/API36原生测试1项通过,系统Chromium133.0.6943.137。原生控制台固定分类、实际页面状态脚本与解码及敏感夹具文本不外泄均验证;离线data页面不满足生产学校域名要求,脚本/解码在原生夹具验证,域名与文档代次保护由独立回归覆盖。
|
||||
- 独立审查发现同URL重新加载时旧诊断可能混入,已用两所学校现有页面代次和生命周期回调校验修复,并观察到回归RED→GREEN;beangle=null误判也已修复。最终258项测试及静态检查均通过。审查及处理见tpschedule-0.7.2-review.md。
|
||||
|
||||
## 后续复测
|
||||
|
||||
本版为诊断细化版。仍需用户覆盖安装、复现登录后缺组件、立即新建反馈附带日志,依据错误细类和页面依赖状态判断下一步修复。不得把本版发布或测试通过表述为真实教务页面已恢复。
|
||||
|
||||
## 最终交付核对
|
||||
|
||||
发布与调试APK构建成功;应用包名com.tpschedule.app,versionName=0.7.2、versionCode=20。发布包60106575字节,SHA256:24564624C9A1A908BE6A1A19EBB8C47089AE6ADF20D80E03873992D7F885F9DC。提供带版本名的TPSchedule-0.7.2.apk与稳定名称TPSchedule.apk,两者字节一致。
|
||||
|
||||
与0.7.1签名证书逐字节相同;证书SHA256为AF5C21B1BA1E48E461D8EA38DF0FE1C3653F64EB7E8E7728BAE6E5D988BA0D0A。原私钥及登记冻结18项校验未变;0.7.1、0.7.0和0.6.2安装包备份保留。源码包由最终Git提交生成,不含私钥、运行缓存或原始学生附件。
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
# TPSchedule Flutter工程
|
||||
|
||||
当前版本0.7.1+19,安卓包名 `com.tpschedule.app`。Dart包名保留 `hepan_everyday`,兼容既有代码引用。
|
||||
当前版本0.7.2+20,安卓包名 `com.tpschedule.app`。Dart包名保留 `hepan_everyday`,兼容既有代码引用。
|
||||
|
||||
学校浏览器在首次导航前配置宽视口及第三方Cookie,显示系统Chromium版本,记录脱敏资源错误;本科认证跳转期间过期的探测会重试。255项单元测试、Android16原生浏览器测试通过,实际学校账号在线加载仍需手机验证,见 `../docs/tpschedule-0.7.1-validation.md`。
|
||||
学校浏览器沿用宽视口及第三方Cookie配置、Chromium版本显示和跳转重试;0.7.2细化脚本错误和页面依赖状态,258项单元测试通过。实际教务页面组件缺失仍未确认根因,见 `../docs/tpschedule-0.7.2-validation.md`。
|
||||
|
||||
## 本科使用
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:hepan_everyday/features/school/school_browser.dart';
|
||||
import 'package:hepan_everyday/features/school/school_browser_diagnostics.dart';
|
||||
import 'package:hepan_everyday/services/diagnostic_log.dart';
|
||||
|
||||
void main() {
|
||||
@@ -31,7 +32,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
const html =
|
||||
'<!doctype html><html><head><meta name="viewport" content="width=1200"></head>'
|
||||
'<body>Legacy teaching portal fixture</body></html>';
|
||||
'<body>PRIVATE_NATIVE_PAGE_TEXT</body></html>';
|
||||
await web.loadHtmlString(html);
|
||||
await loaded.future.timeout(const Duration(seconds: 20));
|
||||
final before = num.parse(
|
||||
@@ -51,11 +52,32 @@ void main() {
|
||||
expect(before, lessThan(1000));
|
||||
expect(after, greaterThanOrEqualTo(1100));
|
||||
expect(info.chromiumVersion, matches(r'^\d+(\.\d+){1,3}$'));
|
||||
await web.runJavaScript('console.error("PRIVATE_NATIVE_CONSOLE_DETAIL")');
|
||||
await web.runJavaScript(
|
||||
'console.error("Uncaught ReferenceError: jQuery is not defined PRIVATE_NATIVE_CONSOLE_DETAIL")',
|
||||
);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
// Native data fixtures use about:blank; test the script and decoder here.
|
||||
// Separate tests cover production origin and document-generation guards.
|
||||
dynamic fixtureUrl = await web.runJavaScriptReturningResult(
|
||||
'location.href',
|
||||
);
|
||||
if ((fixtureUrl as String).startsWith('"')) {
|
||||
fixtureUrl = jsonDecode(fixtureUrl);
|
||||
}
|
||||
final rawState = await web.runJavaScriptReturningResult(
|
||||
schoolPageDiagnosticsScript.replaceAll(
|
||||
'__EXPECTED_URL__',
|
||||
jsonEncode(fixtureUrl),
|
||||
),
|
||||
);
|
||||
recordSchoolPageState(DiagnosticLog.instance, 'undergraduate', rawState);
|
||||
final log = utf8.decode(await DiagnosticLog.instance.export());
|
||||
expect(log, contains('school_console_error'));
|
||||
expect(log, contains('"code":"jquery_missing"'));
|
||||
expect(log, contains('"code":"jquery","result":false'));
|
||||
expect(log, contains('school_asset_state'));
|
||||
expect(log, isNot(contains('PRIVATE_NATIVE_CONSOLE_DETAIL')));
|
||||
expect(log, isNot(contains('PRIVATE_NATIVE_PAGE_TEXT')));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
const appVersion = '0.7.1';
|
||||
const appBuildNumber = 19;
|
||||
const appVersion = '0.7.2';
|
||||
const appBuildNumber = 20;
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||
|
||||
import '../../services/diagnostic_log.dart';
|
||||
import 'school_browser_diagnostics.dart';
|
||||
|
||||
class SchoolBrowserInfo {
|
||||
const SchoolBrowserInfo({this.chromiumVersion});
|
||||
@@ -27,18 +28,9 @@ Future<SchoolBrowserInfo> configureSchoolBrowser(
|
||||
await cookies.setAcceptThirdPartyCookies(android, true);
|
||||
}
|
||||
await controller.setOnConsoleMessage((message) {
|
||||
final text = message.message.toLowerCase();
|
||||
final code = text.contains('mixed content')
|
||||
? 'mixed_content'
|
||||
: text.contains('content security policy')
|
||||
? 'content_security_policy'
|
||||
: text.contains('cors')
|
||||
? 'cross_origin'
|
||||
: text.contains('refused to')
|
||||
? 'resource_refused'
|
||||
: message.level == JavaScriptLogLevel.error
|
||||
? 'script_error'
|
||||
: null;
|
||||
final code =
|
||||
schoolConsoleCode(message.message) ??
|
||||
(message.level == JavaScriptLogLevel.error ? 'script_error' : null);
|
||||
if (code != null) {
|
||||
DiagnosticLog.instance.add(
|
||||
'school_console_error',
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
import '../../services/diagnostic_log.dart';
|
||||
|
||||
/// Convert errors into fixed labels; never export the original console text.
|
||||
String? schoolConsoleCode(String message) {
|
||||
final text = message.toLowerCase();
|
||||
if (text.contains('mixed content')) return 'mixed_content';
|
||||
if (text.contains('content security policy')) {
|
||||
return 'content_security_policy';
|
||||
}
|
||||
if (text.contains('cors')) return 'cross_origin';
|
||||
if (text.contains('refused to')) return 'resource_refused';
|
||||
if (text.contains('referenceerror')) {
|
||||
if (RegExp(r'(?:jquery|\$) is not defined').hasMatch(text)) {
|
||||
return 'jquery_missing';
|
||||
}
|
||||
if (text.contains('beangle is not defined')) return 'beangle_missing';
|
||||
if (text.contains('require is not defined')) return 'require_missing';
|
||||
return 'reference_error';
|
||||
}
|
||||
if (text.contains('syntaxerror')) return 'syntax_error';
|
||||
if (text.contains('typeerror')) return 'type_error';
|
||||
if (text.contains('securityerror')) return 'security_error';
|
||||
if (text.contains('evalerror')) return 'eval_error';
|
||||
if (text.contains('rangeerror')) return 'range_error';
|
||||
return null;
|
||||
}
|
||||
|
||||
// Counts and known library booleans only. This never reads text, forms, cookies,
|
||||
// storage, or resource URLs. A different document or login form returns null.
|
||||
const schoolPageDiagnosticsScript = r'''(() => {
|
||||
if(location.href!==__EXPECTED_URL__ || document.querySelector('input[type="password"]'))return 'null';
|
||||
const links=Array.from(document.querySelectorAll('link[rel~="stylesheet"]'));
|
||||
const resources=performance.getEntriesByType('resource');
|
||||
return JSON.stringify({scripts:document.scripts.length,styles:document.styleSheets.length,
|
||||
links:links.length,loaded:links.filter(link=>link.sheet!==null).length,
|
||||
jquery:typeof window.jQuery==='function',beangle:window.beangle!==null && typeof window.beangle==='object',
|
||||
require:typeof window.require==='function',
|
||||
script202:resources.filter(r=>r.initiatorType==='script' && r.responseStatus===202).length,
|
||||
style202:resources.filter(r=>r.initiatorType==='link' && r.responseStatus===202).length});
|
||||
})()''';
|
||||
|
||||
void recordSchoolPageState(DiagnosticLog log, String school, Object raw) {
|
||||
dynamic value = raw;
|
||||
try {
|
||||
for (var i = 0; i < 2 && value is String; i++) {
|
||||
value = jsonDecode(value);
|
||||
}
|
||||
if (value is! Map) return;
|
||||
const counts = [
|
||||
'scripts',
|
||||
'styles',
|
||||
'links',
|
||||
'loaded',
|
||||
'script202',
|
||||
'style202',
|
||||
];
|
||||
const libraries = ['jquery', 'beangle', 'require'];
|
||||
if (counts.any(
|
||||
(key) => value[key] is! int || value[key] < 0 || value[key] > 100000,
|
||||
) ||
|
||||
libraries.any((key) => value[key] is! bool)) {
|
||||
return;
|
||||
}
|
||||
for (final key in counts) {
|
||||
if ((key == 'script202' || key == 'style202') && value[key] == 0) {
|
||||
continue;
|
||||
}
|
||||
log.add(
|
||||
'school_asset_state',
|
||||
fields: {
|
||||
'school': school,
|
||||
'code': key == 'script202'
|
||||
? 'script_202'
|
||||
: key == 'style202'
|
||||
? 'stylesheet_202'
|
||||
: key,
|
||||
'count': value[key] as int,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (school == 'undergraduate') {
|
||||
for (final key in libraries) {
|
||||
log.add(
|
||||
'school_library_state',
|
||||
fields: {'school': school, 'code': key, 'result': value[key] as bool},
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
/* Diagnostics must not interfere with the school page. */
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> recordSchoolPageDiagnostics(
|
||||
WebViewController web,
|
||||
String school,
|
||||
String url, {
|
||||
bool Function()? isCurrent,
|
||||
}) async {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null ||
|
||||
uri.scheme != 'https' ||
|
||||
!const {'eams.uestc.edu.cn', 'yjsjy.uestc.edu.cn'}.contains(uri.host)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (isCurrent?.call() == false) return;
|
||||
if (await web.currentUrl() != url || isCurrent?.call() == false) return;
|
||||
final raw = await web.runJavaScriptReturningResult(
|
||||
schoolPageDiagnosticsScript.replaceAll(
|
||||
'__EXPECTED_URL__',
|
||||
jsonEncode(url),
|
||||
),
|
||||
);
|
||||
if (await web.currentUrl() != url || isCurrent?.call() == false) {
|
||||
return;
|
||||
}
|
||||
recordSchoolPageState(DiagnosticLog.instance, school, raw);
|
||||
} catch (_) {
|
||||
/* No original exceptions or page data enter the log. */
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import 'school_exam_adapter.dart';
|
||||
import 'school_login_flow.dart';
|
||||
import 'school_page_probe.dart';
|
||||
import 'school_browser.dart';
|
||||
import 'school_browser_diagnostics.dart';
|
||||
|
||||
enum SchoolConnectionState {
|
||||
signedOut,
|
||||
@@ -34,7 +35,9 @@ class SchoolSession extends ChangeNotifier {
|
||||
controller,
|
||||
'graduate',
|
||||
));
|
||||
if (!hasOpened || generation != _pageGeneration || serial != _openSerial) {
|
||||
if (!hasOpened ||
|
||||
generation != _pageGeneration ||
|
||||
serial != _openSerial) {
|
||||
return;
|
||||
}
|
||||
browserVersion = info.chromiumVersion;
|
||||
@@ -106,7 +109,19 @@ class SchoolSession extends ChangeNotifier {
|
||||
_loading = true;
|
||||
notifyListeners();
|
||||
},
|
||||
onPageFinished: _loaded,
|
||||
onPageFinished: (url) {
|
||||
final generation = _pageGeneration;
|
||||
unawaited(
|
||||
recordSchoolPageDiagnostics(
|
||||
web,
|
||||
'graduate',
|
||||
url,
|
||||
isCurrent: () =>
|
||||
hasOpened && !_routeClosed && generation == _pageGeneration,
|
||||
),
|
||||
);
|
||||
unawaited(_loaded(url));
|
||||
},
|
||||
onWebResourceError: (error) async {
|
||||
DiagnosticLog.instance.resourceFailure(
|
||||
'graduate',
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'page_probe.dart';
|
||||
import 'timetable_adapter.dart';
|
||||
import 'exam_adapter.dart';
|
||||
import '../school_browser.dart';
|
||||
import '../school_browser_diagnostics.dart';
|
||||
|
||||
class UndergraduateSession extends ChangeNotifier {
|
||||
UndergraduateSession({this.configureBrowser = configureSchoolBrowser});
|
||||
@@ -138,7 +139,18 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
_emit();
|
||||
}
|
||||
},
|
||||
onPageFinished: _loaded,
|
||||
onPageFinished: (url) {
|
||||
final generation = _generation, document = _document;
|
||||
unawaited(
|
||||
recordSchoolPageDiagnostics(
|
||||
web,
|
||||
'undergraduate',
|
||||
url,
|
||||
isCurrent: () => _valid(generation) && document == _document,
|
||||
),
|
||||
);
|
||||
unawaited(_loaded(url));
|
||||
},
|
||||
onWebResourceError: (error) async {
|
||||
DiagnosticLog.instance.resourceFailure(
|
||||
'undergraduate',
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../app/app_version.dart';
|
||||
|
||||
/// Small, structured breadcrumbs. Never accepts raw URLs, HTML or exceptions.
|
||||
class DiagnosticLog {
|
||||
DiagnosticLog({DateTime Function()? clock}) : clock = clock ?? DateTime.now;
|
||||
@@ -183,7 +185,9 @@ class DiagnosticLog {
|
||||
_trim();
|
||||
await flush();
|
||||
final header =
|
||||
'TPSchedule diagnostic log\nWindow: last 10 minutes (UTC timestamps)\n'
|
||||
'TPSchedule diagnostic log\nApp: TPSchedule $appVersion+$appBuildNumber\n'
|
||||
'Exported at: ${clock().toUtc().toIso8601String()}\n'
|
||||
'Window: last 10 minutes (UTC timestamps)\n'
|
||||
'Up to 500 recent events; no passwords, cookies, HTML or feedback text.\n';
|
||||
return Uint8List.fromList(
|
||||
utf8.encode('$header${_entries.map(jsonEncode).join('\n')}\n'),
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: hepan_everyday
|
||||
description: TPSchedule 学生课表
|
||||
publish_to: none
|
||||
version: 0.7.1+19
|
||||
version: 0.7.2+20
|
||||
environment:
|
||||
sdk: ^3.13.5
|
||||
dependencies:
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const path = require('node:path');
|
||||
const src = fs.readFileSync(path.join(__dirname, '../../lib/features/school/school_browser_diagnostics.dart'), 'utf8');
|
||||
const script = src.match(/schoolPageDiagnosticsScript = r'''([\s\S]*?)''';/)[1];
|
||||
const url = 'https://eams.uestc.edu.cn/eams/home.action';
|
||||
function state({redirect = false, password = false} = {}) {
|
||||
const document = {
|
||||
querySelector(s) { assert.equal(s, 'input[type="password"]'); return password ? {} : null; },
|
||||
querySelectorAll(s) { assert.equal(s, 'link[rel~="stylesheet"]'); return [{sheet: {}}, {sheet: null}]; },
|
||||
scripts: Array(4), styleSheets: Array(1),
|
||||
};
|
||||
// Reading private data or resource URLs must never be necessary.
|
||||
for (const key of ['cookie', 'body', 'forms']) Object.defineProperty(document, key, {get() { throw Error('PRIVATE_DATA_READ'); }});
|
||||
const resources = [
|
||||
{initiatorType: 'script', responseStatus: 202},
|
||||
{initiatorType: 'link', responseStatus: 202},
|
||||
{initiatorType: 'script', responseStatus: 200},
|
||||
{initiatorType: 'fetch', responseStatus: 202},
|
||||
];
|
||||
for (const r of resources) Object.defineProperty(r, 'name', {get() { throw Error('PRIVATE_RESOURCE_URL_READ'); }});
|
||||
return JSON.parse(vm.runInNewContext(script.replace('__EXPECTED_URL__', JSON.stringify(url)), {
|
||||
document, location: {href: redirect ? 'https://idas.uestc.edu.cn/authserver/login' : url},
|
||||
window: {jQuery() {}, require: {}, beangle: null}, performance: {getEntriesByType(type) { assert.equal(type, 'resource'); return resources; }},
|
||||
}));
|
||||
}
|
||||
assert.equal(state({redirect: true}), null);
|
||||
assert.equal(state({password: true}), null);
|
||||
assert.deepEqual(state(), {scripts: 4, styles: 1, links: 2, loaded: 1, jquery: true, beangle: false, require: false, script202: 1, style202: 1});
|
||||
console.log('Browser diagnostics: actual counts, fixed library flags, redirect/password guards, no private data reads passed.');
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hepan_everyday/features/school/school_browser_diagnostics.dart';
|
||||
import 'package:hepan_everyday/services/diagnostic_log.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
import '../support/fake_school_web.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
test('a same URL reload invalidates a pending page diagnostic', () async {
|
||||
final platform = FakeSchoolPlatform();
|
||||
WebViewPlatform.instance = platform;
|
||||
final web = WebViewController();
|
||||
const url = 'https://eams.uestc.edu.cn/eams/home.action';
|
||||
platform.web.url = url;
|
||||
final pending = Completer<Object>();
|
||||
platform.web.javascript = (_) => pending.future;
|
||||
var current = true;
|
||||
final capture = recordSchoolPageDiagnostics(
|
||||
web,
|
||||
'undergraduate',
|
||||
url,
|
||||
isCurrent: () => current,
|
||||
);
|
||||
await flushSchoolEvents();
|
||||
current = false;
|
||||
pending.complete(
|
||||
jsonEncode({
|
||||
'scripts': 9345,
|
||||
'styles': 0,
|
||||
'links': 0,
|
||||
'loaded': 0,
|
||||
'jquery': false,
|
||||
'beangle': false,
|
||||
'require': false,
|
||||
'script202': 0,
|
||||
'style202': 0,
|
||||
}),
|
||||
);
|
||||
await capture;
|
||||
expect(
|
||||
utf8.decode(await DiagnosticLog.instance.export()),
|
||||
isNot(contains('"count":9345')),
|
||||
);
|
||||
});
|
||||
test('legacy library failures and syntax failures retain useful fixed categories', () {
|
||||
expect(
|
||||
schoolConsoleCode('Uncaught ReferenceError: jQuery is not defined'),
|
||||
'jquery_missing',
|
||||
);
|
||||
expect(
|
||||
schoolConsoleCode(r'Uncaught ReferenceError: $ is not defined'),
|
||||
'jquery_missing',
|
||||
);
|
||||
expect(
|
||||
schoolConsoleCode('Uncaught ReferenceError: beangle is not defined'),
|
||||
'beangle_missing',
|
||||
);
|
||||
expect(
|
||||
schoolConsoleCode('Uncaught ReferenceError: require is not defined'),
|
||||
'require_missing',
|
||||
);
|
||||
expect(
|
||||
schoolConsoleCode("Uncaught SyntaxError: Unexpected token '<'"),
|
||||
'syntax_error',
|
||||
);
|
||||
expect(
|
||||
schoolConsoleCode(
|
||||
'Uncaught TypeError: PRIVATE_STUDENT is not a function',
|
||||
),
|
||||
'type_error',
|
||||
);
|
||||
expect(
|
||||
schoolConsoleCode(
|
||||
'Uncaught ReferenceError: PRIVATE_TOKEN is not defined',
|
||||
),
|
||||
'reference_error',
|
||||
);
|
||||
expect(
|
||||
schoolConsoleCode('Blocked by CORS policy: PRIVATE_URL'),
|
||||
'cross_origin',
|
||||
);
|
||||
expect(schoolConsoleCode('normal PRIVATE_CONSOLE message'), isNull);
|
||||
});
|
||||
|
||||
test('page state accepts only bounded typed diagnostics and rejects malformed state', () async {
|
||||
final log = DiagnosticLog(clock: () => DateTime.utc(2026, 10, 15, 5));
|
||||
final uri = Uri.parse('https://eams.uestc.edu.cn/eams/home.action');
|
||||
final data = {
|
||||
'scripts': 12,
|
||||
'styles': 0,
|
||||
'links': 4,
|
||||
'loaded': 0,
|
||||
'jquery': false,
|
||||
'beangle': false,
|
||||
'require': false,
|
||||
'script202': 3,
|
||||
'style202': 2,
|
||||
'html': 'PRIVATE_HTML',
|
||||
'cookie': 'PRIVATE_COOKIE',
|
||||
};
|
||||
recordSchoolPageState(log, 'undergraduate', jsonEncode(data));
|
||||
var text = utf8.decode(await log.export());
|
||||
expect(text, contains('"code":"jquery","result":false'));
|
||||
expect(text, contains('"code":"stylesheet_202","count":2'));
|
||||
expect(text, isNot(contains('PRIVATE_')));
|
||||
expect(text, isNot(contains(uri.toString())));
|
||||
final before = text;
|
||||
recordSchoolPageState(log, 'undergraduate', 'null');
|
||||
recordSchoolPageState(
|
||||
log,
|
||||
'undergraduate',
|
||||
jsonEncode({'scripts': -1, 'jquery': 'PRIVATE_TEXT'}),
|
||||
);
|
||||
text = utf8.decode(await log.export());
|
||||
expect(text, before);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user