Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/scripts/summarize_test_timing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Summarize Flutter JSON reporter timings without treating overlapping tests as wall time."""

import json
import sys
from pathlib import Path


def summarize(events):
starts = {}
durations = []
elapsed = 0
complete = False

for event in events:
elapsed = max(elapsed, event.get("time", 0))

if event["type"] == "testStart":
starts[event["test"]["id"]] = (event["test"]["name"], event["time"])
elif event["type"] == "testDone":
start = starts.pop(event["testID"], None)

if start and not event.get("hidden") and not event.get("skipped"):
name, begin = start
durations.append((event["time"] - begin, name, event.get("result", "unknown")))
elif event["type"] == "done":
complete = True

state = "complete" if complete else "partial"
lines = [
"### Flutter test durations",
"",
f"Test reporter elapsed: {elapsed / 1000:.2f}s ({state} report).",
"This excludes work before reporter startup, such as browser compilation. Use job timings for the full CI cost.",
"Durations include per-test setup/teardown. Tests can overlap, so their sum is not elapsed or runner time.",
"Skipped and hidden runner events are excluded from the ranking.",
"",
"| Seconds | Result | Test |",
"| ---: | --- | --- |",
]

for duration, name, result in sorted(durations, reverse=True)[:20]:
name = name.replace("|", "\\|").replace("\n", " ").replace("`", "'")
lines.append(f"| {duration / 1000:.2f} | {result} | {name} |")

return "\n".join(lines) + "\n"


if __name__ == "__main__":
with Path(sys.argv[1]).open(encoding="utf-8") as source:
print(summarize(json.loads(line) for line in source if line.strip()), end="")
37 changes: 37 additions & 0 deletions .github/scripts/test_summarize_test_timing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import unittest

from summarize_test_timing import summarize


class TestTimingTest(unittest.TestCase):
def test_overlapping_tests_do_not_inflate_elapsed_and_failures_are_ranked(self):
report = summarize([
{"type": "testStart", "time": 0, "test": {"id": 1, "name": "slow"}},
{"type": "testStart", "time": 1000, "test": {"id": 2, "name": "failed | case"}},
{"type": "testDone", "time": 3000, "testID": 2, "result": "failure"},
{"type": "testDone", "time": 4000, "testID": 1, "result": "success"},
{"type": "done", "time": 4500},
])
self.assertIn("Test reporter elapsed: 4.50s (complete report)", report)
self.assertIn("| 2.00 | failure | failed \\| case |", report)
self.assertLess(report.index("| 4.00"), report.index("| 2.00"))

def test_partial_reports_exclude_skipped_hidden_and_unfinished_tests(self):
events = []

for test_id, name in enumerate(["skipped case", "hidden load", "unfinished"]):
events.append({"type": "testStart", "time": 0, "test": {"id": test_id, "name": name}})

events.extend([
{"type": "testDone", "time": 100, "testID": 0, "skipped": True},
{"type": "testDone", "time": 200, "testID": 1, "hidden": True},
])
report = summarize(events)
self.assertIn("0.20s (partial report)", report)
self.assertNotIn("skipped case", report)
self.assertNotIn("hidden load", report)
self.assertNotIn("unfinished", report)


if __name__ == "__main__":
unittest.main()
55 changes: 53 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ jobs:
- name: Check platform dimensions, artwork and safe zones
run: python tools/generate_icons.py --check

quality:
name: Code quality checks
tests:
name: Dart quality and coverage
runs-on: ubuntu-latest
defaults:
run:
Expand Down Expand Up @@ -77,6 +77,10 @@ jobs:
mkdir -p build/test-results
flutter test --coverage --file-reporter json:build/test-results/tests.json

- name: Summarize test durations
if: ${{ !cancelled() && hashFiles('app/build/test-results/tests.json') != '' }}
run: python3 ../.github/scripts/summarize_test_timing.py build/test-results/tests.json >> "$GITHUB_STEP_SUMMARY"

- name: Generate JUnit report
if: ${{ !cancelled() && (steps.tests.outcome == 'success' || steps.tests.outcome == 'failure') }}
run: |
Expand Down Expand Up @@ -131,3 +135,50 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
disable_search: true

browser:
name: Browser behavior checks
runs-on: ubuntu-latest
defaults:
run:
working-directory: app
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- run: flutter pub get --enforce-lockfile
- name: Check browser bootstrap and cover worker
run: node --test test/web/*.cjs

- name: Check browser cover transport and drag-and-drop
run: |
mkdir -p build/test-results
flutter test --platform chrome --file-reporter json:build/test-results/browser.json \
test/services/book_cover_storage_web_test.dart test/platform/book_import_drop_registration_web_test.dart

- name: Summarize browser test durations
if: ${{ !cancelled() && hashFiles('app/build/test-results/browser.json') != '' }}
run: python3 ../.github/scripts/summarize_test_timing.py build/test-results/browser.json >> "$GITHUB_STEP_SUMMARY"

- name: Save browser test results
if: ${{ !cancelled() && hashFiles('app/build/test-results/browser.json') != '' }}
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: client-browser-test-results-${{ github.run_attempt }}
path: app/build/test-results/browser.json
if-no-files-found: error
retention-days: 14

quality:
name: Code quality checks
if: ${{ always() }}
needs: [tests, browser]
runs-on: ubuntu-latest
steps:
- name: Require both test jobs
env:
TEST_RESULT: ${{ needs.tests.result }}
BROWSER_RESULT: ${{ needs.browser.result }}
run: test "$TEST_RESULT" = success && test "$BROWSER_RESULT" = success
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,12 @@ the client root, or `../../tools/papyrus` from `app/`.
- Analysis: `flutter analyze --no-fatal-warnings --no-fatal-infos` (same policy as CI).
- Focused tests: `flutter test test/<domain>/<name>_test.dart`.
- Suite/coverage: `flutter test --coverage`.
- Web bootstrap: `node --test test/web/flutter_bootstrap_test.cjs`.
- Web bootstrap and cover worker: `node --test test/web/*.cjs`.
- Browser cover transport and drag-and-drop: `flutter test --platform chrome test/services/book_cover_storage_web_test.dart test/platform/book_import_drop_registration_web_test.dart`.
- Web compile for platform-sensitive changes: `flutter build web --dart-define-from-file=.dart_defines`.

CI runs browser checks alongside the Dart coverage job. The existing Code quality checks gate requires both jobs to pass. Test-duration summaries are generated from the retained JSON reports.

Add the narrowest regression for changed behavior. UI changes need a relevant
widget test and visual verification when a running target is available. Analyze
and run relevant tests before broadening to full suites. Report warnings and
Expand Down
154 changes: 95 additions & 59 deletions app/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,33 @@ Future main() async {
class Papyrus extends StatefulWidget {
final SharedPreferences prefs;

const Papyrus({super.key, required this.prefs});
const Papyrus({super.key, required this.prefs})
: _repositoryFactory = null,
_database = null,
_uploads = null,
_importer = null,
_child = null;

@visibleForTesting
const Papyrus.withServices({
super.key,
required this.prefs,
required AuthRepository Function(PapyrusApiConfig config, String profileKey) repositoryFactory,
required PapyrusPowerSyncService database,
required MediaUploadQueue uploads,
required BookImportService importer,
required Widget child,
}) : _repositoryFactory = repositoryFactory,
_database = database,
_uploads = uploads,
_importer = importer,
_child = child;

final AuthRepository Function(PapyrusApiConfig config, String profileKey)? _repositoryFactory;
final PapyrusPowerSyncService? _database;
final MediaUploadQueue? _uploads;
final BookImportService? _importer;
final Widget? _child;

@override
State<Papyrus> createState() => _PapyrusState();
Expand Down Expand Up @@ -222,8 +248,8 @@ class _PapyrusState extends State<Papyrus> {

_dataStore = DataStore();
_preferencesProvider = PreferencesProvider(widget.prefs);
_mediaUploadQueue = MediaUploadQueue(widget.prefs, onWorkAvailable: _processMediaUploads);
_bookImportService = BookImportService();
_mediaUploadQueue = widget._uploads ?? MediaUploadQueue(widget.prefs, onWorkAvailable: _processMediaUploads);
_bookImportService = widget._importer ?? BookImportService();
_authProvider = AuthProvider(widget.prefs, repository: _authRepository);
_acquisitionAvailabilityProvider = AcquisitionAvailabilityProvider(authProvider: _authProvider);

Expand All @@ -233,29 +259,31 @@ class _PapyrusState extends State<Papyrus> {
activeApiConfig: () => _syncSettingsProvider.activeApiConfig,
);

_powerSyncService = PapyrusPowerSyncService(
trackingCapability: () async {
final response = await http
.get(_syncSettingsProvider.activeApiConfig.endpoint('/sync/settings'))
.timeout(const Duration(seconds: 5));
_powerSyncService =
widget._database ??
PapyrusPowerSyncService(
trackingCapability: () async {
final response = await http
.get(_syncSettingsProvider.activeApiConfig.endpoint('/sync/settings'))
.timeout(const Duration(seconds: 5));

if (response.statusCode != 200) {
return 0;
}
if (response.statusCode != 200) {
return 0;
}

return (jsonDecode(response.body) as Map)['tracking_schema_version'] as int? ?? 0;
},
connectorFactory: () => PapyrusPowerSyncConnector(
supportsTracking: () => _powerSyncService.supportsTracking,
trackingSchemaVersion: () => _powerSyncService.trackingSchemaVersion,
authRepository: _authRepository,
config: _syncSettingsProvider.activeApiConfig,
onUploadComplete: () async {
await _powerSyncService.refreshBookMetadataSyncState();
await _processMediaUploads();
},
),
);
return (jsonDecode(response.body) as Map)['tracking_schema_version'] as int? ?? 0;
},
connectorFactory: () => PapyrusPowerSyncConnector(
supportsTracking: () => _powerSyncService.supportsTracking,
trackingSchemaVersion: () => _powerSyncService.trackingSchemaVersion,
authRepository: _authRepository,
config: _syncSettingsProvider.activeApiConfig,
onUploadComplete: () async {
await _powerSyncService.refreshBookMetadataSyncState();
await _processMediaUploads();
},
),
);

_bookStorageStatusController = BookStorageStatusController(
authProvider: _authProvider,
Expand Down Expand Up @@ -317,6 +345,12 @@ class _PapyrusState extends State<Papyrus> {
}

AuthRepository _buildAuthRepository(PapyrusApiConfig config, String profileKey) {
final factory = widget._repositoryFactory;

if (factory != null) {
return factory(config, profileKey);
}

final tokenStore = TokenStore(SecureRefreshTokenStorage.scoped(profileKey));

return AuthRepository(
Expand Down Expand Up @@ -573,41 +607,43 @@ class _PapyrusState extends State<Papyrus> {
),
ChangeNotifierProvider.value(value: _preferencesProvider),
],
child: Consumer<PreferencesProvider>(
builder: (context, preferencesProvider, child) {
final isEink = preferencesProvider.isEinkMode;
final lightTheme = preferencesProvider.increaseContrast ? AppTheme.highContrastLight : AppTheme.light;
final darkTheme = preferencesProvider.increaseContrast ? AppTheme.highContrastDark : AppTheme.dark;

return MaterialApp.router(
title: 'Papyrus',
debugShowCheckedModeBanner: false,
theme: AppTheme.withTypography(
isEink ? AppTheme.eink : lightTheme,
dyslexiaFont: preferencesProvider.dyslexiaFont,
),
darkTheme: AppTheme.withTypography(
isEink ? AppTheme.eink : darkTheme,
dyslexiaFont: preferencesProvider.dyslexiaFont,
),
highContrastTheme: AppTheme.withTypography(
isEink ? AppTheme.eink : AppTheme.highContrastLight,
dyslexiaFont: preferencesProvider.dyslexiaFont,
),
highContrastDarkTheme: AppTheme.withTypography(
isEink ? AppTheme.eink : AppTheme.highContrastDark,
dyslexiaFont: preferencesProvider.dyslexiaFont,
),
themeMode: isEink ? ThemeMode.light : preferencesProvider.themeMode,
themeAnimationDuration:
isEink || preferencesProvider.reduceAnimations || MediaQuery.disableAnimationsOf(context)
? Duration.zero
: kThemeAnimationDuration,
builder: (context, child) => AppAccessibilityScope(preferences: preferencesProvider, child: child!),
routerConfig: _appRouter.router,
);
},
),
child:
widget._child ??
Consumer<PreferencesProvider>(
builder: (context, preferencesProvider, child) {
final isEink = preferencesProvider.isEinkMode;
final lightTheme = preferencesProvider.increaseContrast ? AppTheme.highContrastLight : AppTheme.light;
final darkTheme = preferencesProvider.increaseContrast ? AppTheme.highContrastDark : AppTheme.dark;

return MaterialApp.router(
title: 'Papyrus',
debugShowCheckedModeBanner: false,
theme: AppTheme.withTypography(
isEink ? AppTheme.eink : lightTheme,
dyslexiaFont: preferencesProvider.dyslexiaFont,
),
darkTheme: AppTheme.withTypography(
isEink ? AppTheme.eink : darkTheme,
dyslexiaFont: preferencesProvider.dyslexiaFont,
),
highContrastTheme: AppTheme.withTypography(
isEink ? AppTheme.eink : AppTheme.highContrastLight,
dyslexiaFont: preferencesProvider.dyslexiaFont,
),
highContrastDarkTheme: AppTheme.withTypography(
isEink ? AppTheme.eink : AppTheme.highContrastDark,
dyslexiaFont: preferencesProvider.dyslexiaFont,
),
themeMode: isEink ? ThemeMode.light : preferencesProvider.themeMode,
themeAnimationDuration:
isEink || preferencesProvider.reduceAnimations || MediaQuery.disableAnimationsOf(context)
? Duration.zero
: kThemeAnimationDuration,
builder: (context, child) => AppAccessibilityScope(preferences: preferencesProvider, child: child!),
routerConfig: _appRouter.router,
);
},
),
);
}
}
Expand Down
5 changes: 5 additions & 0 deletions app/lib/reader/reader_book_adapter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ final class ReaderBookAdapter {
foregroundColor: colors.onSurface,
brightness: colors.brightness,
layoutMode: preferences.readingMode == 'scroll' ? ReaderLayoutMode.scroll : ReaderLayoutMode.paginated,
pageTurnAnimation: preferences.pageTurnAnimation,
);
}

Expand Down Expand Up @@ -162,6 +163,10 @@ final class ReaderBookAdapter {
target.margins = margins;
}

if (target.pageTurnAnimation != preferences.pageTurnAnimation) {
target.pageTurnAnimation = preferences.pageTurnAnimation;
}

final readingMode = preferences.layoutMode == ReaderLayoutMode.scroll ? 'scroll' : 'paginated';

if (target.readingMode != readingMode) {
Expand Down
6 changes: 1 addition & 5 deletions app/lib/reader/reader_library_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,7 @@ class ReaderLibraryPanel extends StatelessWidget {

return Column(
children: [
if (panel.showHeader)
ListTile(
title: const Text('Contents'),
trailing: IconButton(tooltip: 'Close contents', onPressed: panel.close, icon: const Icon(Icons.close)),
),
if (panel.showHeader) panel.buildHeader!(context, 'Contents'),
const TabBar(
isScrollable: true,
tabAlignment: TabAlignment.start,
Expand Down
Loading
Loading