Skip to content

Commit 3fb980e

Browse files
committed
[red] Require honest accessible GitHub import states
Signed-off-by: Viwat Vchirawongkwin <viwat.v@chula.ac.th>
1 parent 2707173 commit 3fb980e

3 files changed

Lines changed: 217 additions & 10 deletions

File tree

‎app/test/unit/github_board_importer_test.dart‎

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,7 @@ final class _FakeGithubApi extends Fake implements GithubApi {
5656

5757
final class _SessionRecordingConnection extends RecordingConnection
5858
implements ConnectionSessionStampSource {
59-
_SessionRecordingConnection(
60-
this.operationLog, {
59+
_SessionRecordingConnection({
6160
List<List<RemoteEntry>>? directoryListings,
6261
this.failPutAt,
6362
this.holdPutAt,
@@ -66,7 +65,6 @@ final class _SessionRecordingConnection extends RecordingConnection
6665
_sessionStamp = Object(),
6766
super(initial: ConnState.ready);
6867

69-
final List<String> operationLog;
7068
final List<List<RemoteEntry>> directoryListings;
7169
final int? failPutAt;
7270
final int? holdPutAt;
@@ -140,15 +138,15 @@ final class _Harness {
140138
}) {
141139
// Share one chronological log so tests can prove that no PUT races ahead
142140
// of a pending or unvalidated Git blob.
143-
final List<String> operationLog = <String>[];
141+
final _SessionRecordingConnection connection = _SessionRecordingConnection(
142+
directoryListings: directoryListings,
143+
failPutAt: failPutAt,
144+
holdPutAt: holdPutAt,
145+
);
146+
final List<String> operationLog = connection.operationLog;
144147
return _Harness._(
145148
operationLog: operationLog,
146-
connection: _SessionRecordingConnection(
147-
operationLog,
148-
directoryListings: directoryListings,
149-
failPutAt: failPutAt,
150-
holdPutAt: holdPutAt,
151-
),
149+
connection: connection,
152150
api: _FakeGithubApi(operationLog),
153151
cwd: cwd,
154152
);
@@ -331,6 +329,23 @@ void main() {
331329
},
332330
);
333331

332+
test('rejects a selection spanning more than one remote folder', () async {
333+
final _Harness h = harness();
334+
335+
await expectLater(
336+
h.importer.review(_repository, <GithubEntry>[
337+
_file('alpha.py', remotePath: 'examples/alpha.py', object: 30),
338+
_file('beta.py', remotePath: 'other/beta.py', object: 31),
339+
]),
340+
throwsA(_failure(GithubFailureKind.invalidTarget)),
341+
);
342+
expect(
343+
h.operationLog,
344+
isEmpty,
345+
reason: 'folder scope is target validation and must precede board I/O',
346+
);
347+
});
348+
334349
test('a directory at an exact target is a blocking conflict', () async {
335350
final _Harness h = harness(
336351
directoryListings: <List<RemoteEntry>>[

‎app/test/unit/github_repository_client_test.dart‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,30 @@ void main() {
477477
);
478478
}
479479
});
480+
481+
test(
482+
'rejects a blob size that disagrees with its selected tree entry',
483+
() async {
484+
final GithubRepositoryClient api = GithubRepositoryClient(
485+
httpClient: MockClient(
486+
(http.Request request) async => http.Response(
487+
jsonEncode(<String, Object?>{
488+
'sha': _helloBlobSha,
489+
'size': 3,
490+
'encoding': 'base64',
491+
'content': base64.encode(utf8.encode('abc')),
492+
}),
493+
200,
494+
),
495+
),
496+
);
497+
498+
await expectLater(
499+
api.fetchFile(_pinned(), _helloEntry()),
500+
throwsA(_githubFailure(GithubFailureKind.malformedResponse)),
501+
);
502+
},
503+
);
480504
});
481505

482506
group('GithubRepositoryClient failures', () {

‎app/test/widget/github_import_view_test.dart‎

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
// Connection seam; no GitHub account, radio, or proprietary implementation is
88
// involved.
99

10+
import 'dart:async';
1011
import 'dart:convert';
1112
import 'dart:typed_data';
1213

@@ -73,6 +74,9 @@ final class _FakeGithubApi implements GithubApi {
7374
'hello.py': "print('hello')\n",
7475
'blink.py': 'print(1)\n',
7576
},
77+
this.resolveFailure,
78+
this.resolveGate,
79+
this.listGate,
7680
}) : _bytesByRemotePath = <String, Uint8List>{
7781
for (final MapEntry<String, String> source
7882
in sourceByRemotePath.entries)
@@ -81,6 +85,9 @@ final class _FakeGithubApi implements GithubApi {
8185

8286
final List<GithubEntry> entries;
8387
final Map<String, Uint8List> _bytesByRemotePath;
88+
final GithubFailure? resolveFailure;
89+
final Completer<void>? resolveGate;
90+
final Completer<void>? listGate;
8491
final List<(RepositoryLocator, String)> resolveCalls =
8592
<(RepositoryLocator, String)>[];
8693
final List<String> listedRemotePaths = <String>[];
@@ -92,6 +99,8 @@ final class _FakeGithubApi implements GithubApi {
9299
String ref = '',
93100
}) async {
94101
resolveCalls.add((locator, ref));
102+
await resolveGate?.future;
103+
if (resolveFailure case final GithubFailure failure) throw failure;
95104
return PinnedRepository(
96105
locator: locator,
97106
requestedRef: ref,
@@ -108,6 +117,7 @@ final class _FakeGithubApi implements GithubApi {
108117
required String remotePath,
109118
}) async {
110119
listedRemotePaths.add(remotePath);
120+
await listGate?.future;
111121
return GithubDirectory(
112122
treeSha: treeSha,
113123
remotePath: remotePath,
@@ -125,6 +135,25 @@ final class _FakeGithubApi implements GithubApi {
125135
}
126136
}
127137

138+
final class _FailingPutConnection extends RecordingConnection {
139+
_FailingPutConnection() : super(initial: ConnState.ready);
140+
141+
int _putAttempt = 0;
142+
143+
@override
144+
Future<void> putFile(
145+
String path,
146+
Uint8List bytes, {
147+
ProgressCb? onProgress,
148+
}) async {
149+
_putAttempt += 1;
150+
if (_putAttempt == 2) {
151+
injectError(const EIo('scripted second PUT failure'));
152+
}
153+
await super.putFile(path, bytes, onProgress: onProgress);
154+
}
155+
}
156+
128157
Key _entryKey(String remotePath) => ValueKey<String>('githubEntry_$remotePath');
129158

130159
Key _selectionKey(String remotePath) =>
@@ -308,5 +337,144 @@ void main() {
308337
expect(connection.runSourceCalls, isEmpty);
309338
},
310339
);
340+
341+
testWidgets(
342+
'resolve, folder loading, rate guidance, and error focus are explicit',
343+
(WidgetTester tester) async {
344+
final RecordingConnection connection = RecordingConnection(
345+
initial: ConnState.ready,
346+
);
347+
final Completer<void> resolveGate = Completer<void>();
348+
final Completer<void> listGate = Completer<void>();
349+
final _FakeGithubApi loadingApi = _FakeGithubApi(
350+
resolveGate: resolveGate,
351+
listGate: listGate,
352+
);
353+
addTearDown(connection.dispose);
354+
355+
await _openImport(tester, connection, loadingApi);
356+
await tester.enterText(
357+
find.byKey(kGithubRepositoryFieldKey),
358+
'https://github.com/PyBLE-dev/examples',
359+
);
360+
await tester.tap(find.byKey(kGithubBrowseButtonKey));
361+
await tester.pump();
362+
expect(find.text('Resolving repository…'), findsOneWidget);
363+
364+
resolveGate.complete();
365+
await tester.pump();
366+
expect(find.text('Loading repository folder…'), findsOneWidget);
367+
listGate.complete();
368+
await tester.pumpAndSettle();
369+
370+
await tester.tap(find.byTooltip(l10nOf(tester).githubImportClose));
371+
await tester.pumpAndSettle();
372+
373+
final _FakeGithubApi rateLimitedApi = _FakeGithubApi(
374+
resolveFailure: const GithubFailure(
375+
GithubFailureKind.rateLimited,
376+
rateLimitRemaining: 0,
377+
retryAfter: Duration(seconds: 120),
378+
),
379+
);
380+
await _openImport(tester, connection, rateLimitedApi);
381+
await _browse(tester);
382+
383+
expect(find.textContaining('120'), findsOneWidget);
384+
final Finder failure = find.byKey(
385+
const ValueKey<String>('githubImportFailure'),
386+
);
387+
expect(failure, findsOneWidget);
388+
expect(tester.widget<Focus>(failure).focusNode?.hasFocus, isTrue);
389+
expect(
390+
tester.getSemantics(failure),
391+
matchesSemantics(isLiveRegion: true),
392+
);
393+
},
394+
);
395+
396+
testWidgets('dismissal returns focus to the invoking Files action', (
397+
WidgetTester tester,
398+
) async {
399+
final RecordingConnection connection = RecordingConnection(
400+
initial: ConnState.ready,
401+
);
402+
final _FakeGithubApi api = _FakeGithubApi();
403+
addTearDown(connection.dispose);
404+
await pumpSurface(
405+
tester,
406+
const FilesView(),
407+
connection: connection,
408+
extra: <Override>[githubApiProvider.overrideWithValue(api)],
409+
);
410+
await tester.pumpAndSettle();
411+
412+
final Finder tooltip = find.byTooltip(l10nOf(tester).githubImportAction);
413+
final Finder buttonFinder = find.ancestor(
414+
of: tooltip,
415+
matching: find.byType(IconButton),
416+
);
417+
final FocusNode? invokingFocus = tester
418+
.widget<IconButton>(buttonFinder)
419+
.focusNode;
420+
expect(invokingFocus, isNotNull);
421+
422+
await tester.tap(tooltip);
423+
await tester.pumpAndSettle();
424+
await tester.tap(find.byTooltip(l10nOf(tester).githubImportClose));
425+
await tester.pumpAndSettle();
426+
427+
expect(invokingFocus!.hasFocus, isTrue);
428+
});
429+
430+
testWidgets(
431+
'partial result labels succeeded, failed, and unattempted paths and announces once',
432+
(WidgetTester tester) async {
433+
final _FailingPutConnection connection = _FailingPutConnection();
434+
final GithubEntry third = GithubEntry(
435+
name: 'third.py',
436+
remotePath: 'third.py',
437+
kind: GithubEntryKind.regularFile,
438+
objectSha: '7777777777777777777777777777777777777777',
439+
declaredSize: 9,
440+
);
441+
final _FakeGithubApi api = _FakeGithubApi(
442+
entries: <GithubEntry>[_helloEntry, _blinkEntry, third],
443+
sourceByRemotePath: const <String, String>{
444+
'hello.py': "print('hello')\n",
445+
'blink.py': 'print(1)\n',
446+
'third.py': 'print(3)\n',
447+
},
448+
);
449+
addTearDown(connection.dispose);
450+
451+
await _openImport(tester, connection, api);
452+
await _browse(tester);
453+
for (final String path in <String>[
454+
'hello.py',
455+
'blink.py',
456+
'third.py',
457+
]) {
458+
await tester.tap(find.byKey(_selectionKey(path)));
459+
}
460+
await tester.pump();
461+
await tester.tap(find.byKey(kGithubReviewButtonKey));
462+
await tester.pumpAndSettle();
463+
await tester.tap(find.byKey(kGithubCommitButtonKey));
464+
await tester.pumpAndSettle();
465+
466+
expect(find.text('Downloaded'), findsOneWidget);
467+
expect(find.text('Failed'), findsOneWidget);
468+
expect(find.text('Not attempted'), findsOneWidget);
469+
final Finder result = find.byKey(
470+
const ValueKey<String>('githubImportResult'),
471+
);
472+
expect(result, findsOneWidget);
473+
expect(
474+
tester.getSemantics(result),
475+
matchesSemantics(isLiveRegion: true),
476+
);
477+
},
478+
);
311479
});
312480
}

0 commit comments

Comments
 (0)