Files
yellowjacket/frontend/test/components/folder-picker.test.ts
T
logan e14a34fccf fix(android): let the app reach the user's music
Three of plan 016's four blockers. Each is a different reason the app
could not work at all on a phone.

**It had no permission to read anything.** The generated manifest asked
for INTERNET, VIBRATE, biometrics, location and a camera, and nothing
whatever about storage -- so at targetSdk 35 the app could see its own
private directory and no music. It now declares READ_MEDIA_AUDIO, the
two capped legacy storage permissions, and MANAGE_EXTERNAL_STORAGE.

That last one is deliberate and is the load-bearing choice. This app is
a library manager: audio_files.file_path is the primary key of
ownership, the scanner walks a directory the user chose, and tagwriter
rewrites files in place. MediaStore offers no stable directory to walk
and no in-place write, so scoped storage is not "more work" here, it is
a different application. MANAGE_EXTERNAL_STORAGE is Play-restricted,
which is acceptable only because this ships as an APK through the
package registry -- if it ever targets Play, that line is what has to
go, and plan 016 says what replaces it.

It is granted on a Settings screen rather than in a dialog, so it
cannot be requested with requestPermissions(). MainActivity opens that
screen on every cold start until access exists -- there is no degraded
mode worth offering -- and re-checks in onResume, because the way back
from another task is a resume, emitting android:storageAccess so the
frontend can react.

**The first-run flow could not complete.** All three call sites asked
for a folder through the Wails dialog, which returns an error on
Android: SAF yields tree URIs and this app is keyed on paths. So the
app browses the filesystem itself, which it can now do. ListDirectories
lists directories only (the thing being chosen is a library root),
skips what it cannot stat rather than failing the listing (Android's
storage root holds directories no app may enter), follows symlinks
(os.DirEntry reports the link, so a symlinked music folder would
silently vanish), and hides dotted entries.

utils/pick-directory.ts is the one place that chooses between the two,
so the three call sites changed by one line each. **Which platform is
asked of the backend**, not of System.IsAndroid(): the dialog is
backend code, so the backend is what knows whether it can open one; it
answers for iOS at the same time; and it keeps the fallback testable
through the ordinary transport fake rather than a module mock of the
Wails runtime, whose platform helpers read build constants.

**And MPRIS was compiled into the Android build**, because android
implies the linux build tag, so it went looking for a session bus that
does not exist. mpris_linux.go is `linux && !android` now and the stub
covers Android, which means no lock-screen transport there yet -- a
missing feature rather than a broken one, and the remaining blocker.

The foreground service is typed mediaPlayback rather than the
scaffold's dataSync, with the matching permission, so playback can
survive the screen locking once there is a MediaSession to drive it.
The type in the manifest and the one passed to startForeground must
agree or startForeground throws.
2026-08-16 17:18:03 -04:00

207 lines
6.0 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { wails } from '../support/wails-fake';
import type { FolderPicker } from '@components/folder-picker/folder-picker';
const listings: Record<string, unknown> = {
'/storage/emulated/0': {
path: '/storage/emulated/0',
parent: '/storage/emulated',
entries: [
{ name: 'Music', path: '/storage/emulated/0/Music' },
{ name: 'Podcasts', path: '/storage/emulated/0/Podcasts' },
],
},
'/storage/emulated/0/Music': {
path: '/storage/emulated/0/Music',
parent: '/storage/emulated/0',
entries: [],
},
};
/**
* `pickDirectory` imports the picker's chunk before it can create the
* element, so the element does not exist on the turn the call is made.
* That is deliberate -- mounting a dialog and calling showModal() in
* one update is the trap `index.ts` documents -- so the test waits for
* it rather than assuming it is synchronous.
*/
async function host(): Promise<FolderPicker> {
for (let i = 0; i < 50; i++) {
const el = document.querySelector<FolderPicker>('folder-picker');
if (el) {
await el.updateComplete;
return el;
}
await new Promise((r) => setTimeout(r, 10));
}
throw new Error('folder-picker did not mount itself');
}
function click(el: FolderPicker, testid: string): void {
el.shadowRoot
?.querySelector<HTMLButtonElement>(`[data-testid="${testid}"]`)
?.click();
}
beforeEach(() => {
wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', true);
wails.stub('frontendutil.FrontendUtil.DirectoryPicker', '/home/logan/Music');
wails.stub('frontendutil.FrontendUtil.DefaultBrowseRoot', '/storage/emulated/0');
wails.stub('frontendutil.FrontendUtil.ListDirectories', (path: string) => {
const listing = listings[path || '/storage/emulated/0'];
if (!listing) throw new Error('permission denied');
return listing;
});
});
afterEach(() => {
document.querySelector('folder-picker')?.remove();
wails.reset();
});
describe('pickDirectory', () => {
it('uses the platform dialog off Android', async () => {
const { pickDirectory, resetDirectoryPickerCache } = await import(
'@utils/pick-directory'
);
resetDirectoryPickerCache();
await expect(pickDirectory()).resolves.toBe('/home/logan/Music');
expect(document.querySelector('folder-picker')).toBeNull();
});
it('normalises the desktop dialog\u2019s empty string to null', async () => {
wails.stub('frontendutil.FrontendUtil.DirectoryPicker', '');
const { pickDirectory, resetDirectoryPickerCache } = await import(
'@utils/pick-directory'
);
resetDirectoryPickerCache();
await expect(pickDirectory()).resolves.toBeNull();
});
it('browses in-app on Android, and never opens the platform dialog', async () => {
wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', false);
const { pickDirectory, resetDirectoryPickerCache } = await import(
'@utils/pick-directory'
);
resetDirectoryPickerCache();
const answer = pickDirectory();
const el = await host();
await new Promise((r) => setTimeout(r, 0));
await el.updateComplete;
click(el, 'folder-picker-select');
await expect(answer).resolves.toBe('/storage/emulated/0');
});
it('resolves null when the browser is cancelled', async () => {
wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', false);
const { pickDirectory, resetDirectoryPickerCache } = await import(
'@utils/pick-directory'
);
resetDirectoryPickerCache();
const answer = pickDirectory();
const el = await host();
await new Promise((r) => setTimeout(r, 0));
await el.updateComplete;
el.shadowRoot
?.querySelectorAll<HTMLButtonElement>('.actions button')[0]
?.click();
await expect(answer).resolves.toBeNull();
});
it('descends into a folder and returns the one it is showing', async () => {
wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', false);
const { pickDirectory, resetDirectoryPickerCache } = await import(
'@utils/pick-directory'
);
resetDirectoryPickerCache();
const answer = pickDirectory();
const el = await host();
await new Promise((r) => setTimeout(r, 0));
await el.updateComplete;
const music = [
...(el.shadowRoot?.querySelectorAll<HTMLButtonElement>(
'[data-testid="folder-picker-list"] button',
) ?? []),
].find((b) => b.textContent?.includes('Music'));
music?.click();
await new Promise((r) => setTimeout(r, 0));
await el.updateComplete;
click(el, 'folder-picker-select');
await expect(answer).resolves.toBe('/storage/emulated/0/Music');
});
/**
* A directory that cannot be read is not a failed picker. Android's
* storage root holds directories no app may enter, and stranding the
* user in an empty dialog with no way back is worse than saying so.
*/
it('stays put and explains when a folder cannot be opened', async () => {
wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', false);
const { pickDirectory, resetDirectoryPickerCache } = await import(
'@utils/pick-directory'
);
resetDirectoryPickerCache();
const answer = pickDirectory();
const el = await host();
await new Promise((r) => setTimeout(r, 0));
await el.updateComplete;
// 'Podcasts' has no listing, so ListDirectories rejects.
const bad = [
...(el.shadowRoot?.querySelectorAll<HTMLButtonElement>(
'[data-testid="folder-picker-list"] button',
) ?? []),
].find((b) => b.textContent?.includes('Podcasts'));
bad?.click();
await new Promise((r) => setTimeout(r, 0));
await el.updateComplete;
expect(el.shadowRoot?.querySelector('[role="alert"]')).toBeTruthy();
expect(
el.shadowRoot?.querySelector('[data-testid="folder-picker-path"]')
?.textContent,
).toContain('/storage/emulated/0');
click(el, 'folder-picker-select');
await expect(answer).resolves.toBe('/storage/emulated/0');
});
});