Refine school script diagnostics and document-state capture

This commit is contained in:
Codex
2026-10-15 15:15:42 +08:00
parent a4c2df838e
commit 83b0c7e177
16 changed files with 435 additions and 27 deletions
+2 -2
View File
@@ -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')));
},
);
}
+2 -2
View File
@@ -1,2 +1,2 @@
const appVersion = '0.7.1';
const appBuildNumber = 19;
const appVersion = '0.7.2';
const appBuildNumber = 20;
+4 -12
View File
@@ -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. */
}
}
+17 -2
View File
@@ -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',
+5 -1
View File
@@ -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
View File
@@ -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);
});
}