Add ESP32 firmware flasher
Browser-based ESP32/ESP8266 flasher (esptool-js + Web Serial); nothing is
uploaded to WROLPi, the flash is written straight to the device over USB.
Firmware sources (themed, tabbed): a saved set, the WROLPi media directory,
or a local file. Local files can be dropped anywhere on the page, which jumps
to the "Add from computer" dropzone. The media picker searches .bin files by
path and can detect the connected chip to filter firmware to matching images.
- Indexed file_group.suffix + path filter for fast .bin search (alembic
migration backfills via split_path_stem_and_suffix)
- modules/flasher: chip detection from ESP image headers, /search endpoint,
and flasher.yaml saved configurations (/configs CRUD)
- Flash progress modal with ETA, connect timeout, boot-mode hint, auto-
scrolling logs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:24:21 -06:00
|
|
|
import pathlib
|
|
|
|
|
from http import HTTPStatus
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from modules.flasher.lib import read_esp_image_info, search_esp_firmware
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize('chip_id,kind,expected_chip,expected_kind', [
|
|
|
|
|
(0x0000, 'app', 'ESP32', 'app'),
|
|
|
|
|
(0x0002, 'app', 'ESP32-S2', 'app'),
|
|
|
|
|
(0x0009, 'app', 'ESP32-S3', 'app'),
|
|
|
|
|
(0x0005, 'app', 'ESP32-C3', 'app'),
|
|
|
|
|
(0x000D, 'app', 'ESP32-C6', 'app'),
|
|
|
|
|
(0x0000, 'factory_s2', 'ESP32', 'factory'), # ESP32/S2 layout: bootloader at 0x1000
|
|
|
|
|
(0x0009, 'factory', 'ESP32-S3', 'factory'), # S3 layout: bootloader at 0x0, partition table at 0x8000
|
|
|
|
|
(0x0000, 'bootloader', 'ESP32', 'bootloader'),
|
|
|
|
|
])
|
|
|
|
|
def test_read_esp_image_info(make_esp_image, chip_id, kind, expected_chip, expected_kind):
|
|
|
|
|
"""The header reader identifies the target chip and image kind from realistic header bytes."""
|
|
|
|
|
path = make_esp_image('software/fw.bin', chip_id=chip_id, kind=kind)
|
|
|
|
|
info = read_esp_image_info(path)
|
|
|
|
|
assert info['is_esp_image'] is True
|
|
|
|
|
assert info['chip'] == expected_chip
|
|
|
|
|
assert info['chip_id'] == chip_id
|
|
|
|
|
assert info['kind'] == expected_kind
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_read_esp_image_info_not_esp(make_esp_image):
|
|
|
|
|
"""A .bin that is not an ESP image (no 0xE9 magic) is reported as such."""
|
|
|
|
|
path = make_esp_image('software/data.bin', kind='not_esp')
|
|
|
|
|
info = read_esp_image_info(path)
|
|
|
|
|
assert info == dict(is_esp_image=False, chip=None, chip_id=None, kind='not_esp_image')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_read_esp_image_info_unknown_chip(make_esp_image):
|
|
|
|
|
"""An ESP image with an unrecognized chip_id is still detected as an image, with chip=None."""
|
|
|
|
|
path = make_esp_image('software/fw.bin', chip_id=0x7777, kind='app')
|
|
|
|
|
info = read_esp_image_info(path)
|
|
|
|
|
assert info['is_esp_image'] is True
|
|
|
|
|
assert info['chip'] is None
|
|
|
|
|
assert info['chip_id'] == 0x7777
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_search_esp_firmware_filters_by_chip(async_client, test_session, make_esp_image, refresh_files):
|
|
|
|
|
"""search_esp_firmware returns only ESP images matching the requested chip, annotated with chip/kind."""
|
|
|
|
|
make_esp_image('software/esp32-app.bin', chip_id=0x0000, kind='app')
|
|
|
|
|
make_esp_image('software/s2-app.bin', chip_id=0x0002, kind='app')
|
|
|
|
|
make_esp_image('software/s3-factory.bin', chip_id=0x0009, kind='factory')
|
|
|
|
|
make_esp_image('software/s3-app.bin', chip_id=0x0009, kind='app')
|
|
|
|
|
# A non-ESP .bin (e.g. a filesystem image or game installer) must never be returned.
|
|
|
|
|
make_esp_image('software/littlefs.bin', kind='not_esp')
|
|
|
|
|
|
|
|
|
|
await refresh_files()
|
|
|
|
|
|
|
|
|
|
def names(results):
|
|
|
|
|
# search_esp_firmware (lib layer) returns primary_path as an absolute Path; compare by basename.
|
|
|
|
|
return sorted(pathlib.Path(r['primary_path']).name for r in results)
|
|
|
|
|
|
|
|
|
|
# Filter to ESP32-S3: only the two S3 images, never the non-ESP file.
|
db: request-path library functions use the request's connection (#774)
* db: request-path library functions use the request's connection
Library functions an API request reaches now take the request's Session (first argument) instead of
opening their own session or cursor, so a request needs one SQLite connection. Raw SQL runs through the
new `wrolpi.db.session_curs(session)`, a cursor on the Session's connection; `RequestDB.curs()` uses it.
- Search: files, videos, archives, flasher, and docs search share the request connection for the total,
the page of ids, and the page's models (3 connections -> 1).
- Zim search, including tag-filtered search and estimates (2 -> 1).
- Channel detail (`Channel.get_statistics` reads on the Channel's own Session) and the channel list.
- Video detail, edit, description, captions, comments and transcode. `get_video_for_app` and
`update_video` no longer commit; their handlers use `request.ctx.db.write()`, so editing a Video and
reading it back is one transaction.
- Tags (list, overlaps, recent), file statistics and estimates, other estimates, bulk tag preview, the
download list, retry, kill and the status summary, and docs/videos/archive statistics.
Not converted: file operations that refresh files inside the request (delete, rename, upload, channel
and collection tag/move) commit page by page in code shared with the file worker; the cached zim entry
lookup; config import.
The connection-count tests now assert one connection for every endpoint they cover (no xfails left), and
cover the videos and archive statistics endpoints, which had no API test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* downloads: startup retries in a write session of its own
start_single_tasks still called retry_downloads() without a Session after it began requiring one, so any
start after a completed refresh raised TypeError there: incomplete downloads were not reset and the
download_on_startup enable after it never ran. Startup is not a request, so
DownloadManager.retry_downloads_on_startup() opens and commits its own short write transaction.
Tested through the method, not start_single_tasks: importing main registers its startup listeners on the
shared app, and every later test in that worker then runs them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:05:20 -06:00
|
|
|
s3, total = search_esp_firmware(test_session, chip='ESP32-S3')
|
Add ESP32 firmware flasher
Browser-based ESP32/ESP8266 flasher (esptool-js + Web Serial); nothing is
uploaded to WROLPi, the flash is written straight to the device over USB.
Firmware sources (themed, tabbed): a saved set, the WROLPi media directory,
or a local file. Local files can be dropped anywhere on the page, which jumps
to the "Add from computer" dropzone. The media picker searches .bin files by
path and can detect the connected chip to filter firmware to matching images.
- Indexed file_group.suffix + path filter for fast .bin search (alembic
migration backfills via split_path_stem_and_suffix)
- modules/flasher: chip detection from ESP image headers, /search endpoint,
and flasher.yaml saved configurations (/configs CRUD)
- Flash progress modal with ETA, connect timeout, boot-mode hint, auto-
scrolling logs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:24:21 -06:00
|
|
|
assert total == 2
|
|
|
|
|
assert names(s3) == ['s3-app.bin', 's3-factory.bin']
|
|
|
|
|
assert {r['esp_chip'] for r in s3} == {'ESP32-S3'}
|
|
|
|
|
assert {r['esp_kind'] for r in s3} == {'app', 'factory'}
|
|
|
|
|
|
|
|
|
|
# Filter to ESP32-S2: just the one.
|
db: request-path library functions use the request's connection (#774)
* db: request-path library functions use the request's connection
Library functions an API request reaches now take the request's Session (first argument) instead of
opening their own session or cursor, so a request needs one SQLite connection. Raw SQL runs through the
new `wrolpi.db.session_curs(session)`, a cursor on the Session's connection; `RequestDB.curs()` uses it.
- Search: files, videos, archives, flasher, and docs search share the request connection for the total,
the page of ids, and the page's models (3 connections -> 1).
- Zim search, including tag-filtered search and estimates (2 -> 1).
- Channel detail (`Channel.get_statistics` reads on the Channel's own Session) and the channel list.
- Video detail, edit, description, captions, comments and transcode. `get_video_for_app` and
`update_video` no longer commit; their handlers use `request.ctx.db.write()`, so editing a Video and
reading it back is one transaction.
- Tags (list, overlaps, recent), file statistics and estimates, other estimates, bulk tag preview, the
download list, retry, kill and the status summary, and docs/videos/archive statistics.
Not converted: file operations that refresh files inside the request (delete, rename, upload, channel
and collection tag/move) commit page by page in code shared with the file worker; the cached zim entry
lookup; config import.
The connection-count tests now assert one connection for every endpoint they cover (no xfails left), and
cover the videos and archive statistics endpoints, which had no API test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* downloads: startup retries in a write session of its own
start_single_tasks still called retry_downloads() without a Session after it began requiring one, so any
start after a completed refresh raised TypeError there: incomplete downloads were not reset and the
download_on_startup enable after it never ran. Startup is not a request, so
DownloadManager.retry_downloads_on_startup() opens and commits its own short write transaction.
Tested through the method, not start_single_tasks: importing main registers its startup listeners on the
shared app, and every later test in that worker then runs them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:05:20 -06:00
|
|
|
s2, total = search_esp_firmware(test_session, chip='ESP32-S2')
|
Add ESP32 firmware flasher
Browser-based ESP32/ESP8266 flasher (esptool-js + Web Serial); nothing is
uploaded to WROLPi, the flash is written straight to the device over USB.
Firmware sources (themed, tabbed): a saved set, the WROLPi media directory,
or a local file. Local files can be dropped anywhere on the page, which jumps
to the "Add from computer" dropzone. The media picker searches .bin files by
path and can detect the connected chip to filter firmware to matching images.
- Indexed file_group.suffix + path filter for fast .bin search (alembic
migration backfills via split_path_stem_and_suffix)
- modules/flasher: chip detection from ESP image headers, /search endpoint,
and flasher.yaml saved configurations (/configs CRUD)
- Flash progress modal with ETA, connect timeout, boot-mode hint, auto-
scrolling logs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:24:21 -06:00
|
|
|
assert total == 1
|
|
|
|
|
assert names(s2) == ['s2-app.bin']
|
|
|
|
|
|
Flasher: chip-mismatch guard, WROLPi firmware table, colorblind chip colors
Prevents the wrong-chip flash that bricks a board (e.g. an ESP32-S2 bootloader
in an ESP32 set): after connecting, each selected file's ESP image header is
checked against the device's chip_id and the offending row is flagged (a
warning, not a block). Changing the firmware selection now disconnects so the
check is always re-run against a freshly detected chip.
- "Choose from your WROLPi" is now a table (File / Path / Size / Chip / Kind
/ add), served by a single /flasher/search call
- search_esp_firmware returns every .bin annotated when unfiltered (non-ESP
parts like partition tables, boot_app0, and littlefs stay pickable); a chip
filter still narrows to that chip's ESP images
- Detect-device filter now searches by the canonical chip name (CHIP_NAME,
e.g. "ESP32") instead of the variant description ("ESP32-D0WD-V3"), which
matched nothing
- Chip column uses deterministic, colorblind-safe colors; same-width chip
names always get distinct colors, and the palette has headroom to grow
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 17:10:19 -06:00
|
|
|
# No chip: every .bin, including non-ESP parts (partition tables, littlefs, boot_app0) annotated with a null
|
|
|
|
|
# chip so a full flash set can still be assembled from the picker.
|
db: request-path library functions use the request's connection (#774)
* db: request-path library functions use the request's connection
Library functions an API request reaches now take the request's Session (first argument) instead of
opening their own session or cursor, so a request needs one SQLite connection. Raw SQL runs through the
new `wrolpi.db.session_curs(session)`, a cursor on the Session's connection; `RequestDB.curs()` uses it.
- Search: files, videos, archives, flasher, and docs search share the request connection for the total,
the page of ids, and the page's models (3 connections -> 1).
- Zim search, including tag-filtered search and estimates (2 -> 1).
- Channel detail (`Channel.get_statistics` reads on the Channel's own Session) and the channel list.
- Video detail, edit, description, captions, comments and transcode. `get_video_for_app` and
`update_video` no longer commit; their handlers use `request.ctx.db.write()`, so editing a Video and
reading it back is one transaction.
- Tags (list, overlaps, recent), file statistics and estimates, other estimates, bulk tag preview, the
download list, retry, kill and the status summary, and docs/videos/archive statistics.
Not converted: file operations that refresh files inside the request (delete, rename, upload, channel
and collection tag/move) commit page by page in code shared with the file worker; the cached zim entry
lookup; config import.
The connection-count tests now assert one connection for every endpoint they cover (no xfails left), and
cover the videos and archive statistics endpoints, which had no API test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* downloads: startup retries in a write session of its own
start_single_tasks still called retry_downloads() without a Session after it began requiring one, so any
start after a completed refresh raised TypeError there: incomplete downloads were not reset and the
download_on_startup enable after it never ran. Startup is not a request, so
DownloadManager.retry_downloads_on_startup() opens and commits its own short write transaction.
Tested through the method, not start_single_tasks: importing main registers its startup listeners on the
shared app, and every later test in that worker then runs them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:05:20 -06:00
|
|
|
all_bin, total = search_esp_firmware(test_session)
|
Flasher: chip-mismatch guard, WROLPi firmware table, colorblind chip colors
Prevents the wrong-chip flash that bricks a board (e.g. an ESP32-S2 bootloader
in an ESP32 set): after connecting, each selected file's ESP image header is
checked against the device's chip_id and the offending row is flagged (a
warning, not a block). Changing the firmware selection now disconnects so the
check is always re-run against a freshly detected chip.
- "Choose from your WROLPi" is now a table (File / Path / Size / Chip / Kind
/ add), served by a single /flasher/search call
- search_esp_firmware returns every .bin annotated when unfiltered (non-ESP
parts like partition tables, boot_app0, and littlefs stay pickable); a chip
filter still narrows to that chip's ESP images
- Detect-device filter now searches by the canonical chip name (CHIP_NAME,
e.g. "ESP32") instead of the variant description ("ESP32-D0WD-V3"), which
matched nothing
- Chip column uses deterministic, colorblind-safe colors; same-width chip
names always get distinct colors, and the palette has headroom to grow
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 17:10:19 -06:00
|
|
|
assert total == 5
|
|
|
|
|
assert 'littlefs.bin' in names(all_bin)
|
|
|
|
|
littlefs = next(r for r in all_bin if pathlib.Path(r['primary_path']).name == 'littlefs.bin')
|
|
|
|
|
assert littlefs['esp_chip'] is None
|
|
|
|
|
assert littlefs['esp_kind'] == 'not_esp_image'
|
|
|
|
|
|
|
|
|
|
# Filtering to a specific chip still returns only that chip's ESP images (non-ESP parts are shown unfiltered).
|
db: request-path library functions use the request's connection (#774)
* db: request-path library functions use the request's connection
Library functions an API request reaches now take the request's Session (first argument) instead of
opening their own session or cursor, so a request needs one SQLite connection. Raw SQL runs through the
new `wrolpi.db.session_curs(session)`, a cursor on the Session's connection; `RequestDB.curs()` uses it.
- Search: files, videos, archives, flasher, and docs search share the request connection for the total,
the page of ids, and the page's models (3 connections -> 1).
- Zim search, including tag-filtered search and estimates (2 -> 1).
- Channel detail (`Channel.get_statistics` reads on the Channel's own Session) and the channel list.
- Video detail, edit, description, captions, comments and transcode. `get_video_for_app` and
`update_video` no longer commit; their handlers use `request.ctx.db.write()`, so editing a Video and
reading it back is one transaction.
- Tags (list, overlaps, recent), file statistics and estimates, other estimates, bulk tag preview, the
download list, retry, kill and the status summary, and docs/videos/archive statistics.
Not converted: file operations that refresh files inside the request (delete, rename, upload, channel
and collection tag/move) commit page by page in code shared with the file worker; the cached zim entry
lookup; config import.
The connection-count tests now assert one connection for every endpoint they cover (no xfails left), and
cover the videos and archive statistics endpoints, which had no API test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* downloads: startup retries in a write session of its own
start_single_tasks still called retry_downloads() without a Session after it began requiring one, so any
start after a completed refresh raised TypeError there: incomplete downloads were not reset and the
download_on_startup enable after it never ran. Startup is not a request, so
DownloadManager.retry_downloads_on_startup() opens and commits its own short write transaction.
Tested through the method, not start_single_tasks: importing main registers its startup listeners on the
shared app, and every later test in that worker then runs them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:05:20 -06:00
|
|
|
_, total = search_esp_firmware(test_session, chip='ESP32-H2')
|
Add ESP32 firmware flasher
Browser-based ESP32/ESP8266 flasher (esptool-js + Web Serial); nothing is
uploaded to WROLPi, the flash is written straight to the device over USB.
Firmware sources (themed, tabbed): a saved set, the WROLPi media directory,
or a local file. Local files can be dropped anywhere on the page, which jumps
to the "Add from computer" dropzone. The media picker searches .bin files by
path and can detect the connected chip to filter firmware to matching images.
- Indexed file_group.suffix + path filter for fast .bin search (alembic
migration backfills via split_path_stem_and_suffix)
- modules/flasher: chip detection from ESP image headers, /search endpoint,
and flasher.yaml saved configurations (/configs CRUD)
- Flash progress modal with ETA, connect timeout, boot-mode hint, auto-
scrolling logs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:24:21 -06:00
|
|
|
assert total == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_flasher_search_api(async_client, test_session, make_esp_image, refresh_files):
|
|
|
|
|
"""The /api/flasher/search endpoint filters by chip and supports the path filter."""
|
|
|
|
|
make_esp_image('software/marauder/esp32.bin', chip_id=0x0000, kind='app')
|
|
|
|
|
make_esp_image('software/marauder/s3.bin', chip_id=0x0009, kind='app')
|
|
|
|
|
make_esp_image('software/other/s3.bin', chip_id=0x0009, kind='app')
|
|
|
|
|
|
|
|
|
|
await refresh_files()
|
|
|
|
|
|
|
|
|
|
# Filter by chip.
|
|
|
|
|
request, response = await async_client.post('/api/flasher/search', json=dict(chip='ESP32-S3'))
|
|
|
|
|
assert response.status_code == HTTPStatus.OK
|
|
|
|
|
got = sorted(fg['primary_path'] for fg in response.json['file_groups'])
|
|
|
|
|
assert got == ['software/marauder/s3.bin', 'software/other/s3.bin']
|
|
|
|
|
assert response.json['totals']['file_groups'] == 2
|
|
|
|
|
|
|
|
|
|
# Filter by chip AND path (the flasher's detect + folder filter combined).
|
|
|
|
|
request, response = await async_client.post('/api/flasher/search',
|
|
|
|
|
json=dict(chip='ESP32-S3', path='marauder'))
|
|
|
|
|
assert response.status_code == HTTPStatus.OK
|
|
|
|
|
got = [fg['primary_path'] for fg in response.json['file_groups']]
|
|
|
|
|
assert got == ['software/marauder/s3.bin']
|
|
|
|
|
assert response.json['file_groups'][0]['esp_chip'] == 'ESP32-S3'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_flasher_saved_configs_api(async_client, test_session, test_directory):
|
|
|
|
|
"""Saved firmware configurations can be created, listed, replaced, deleted, and are persisted to YAML."""
|
|
|
|
|
# Initially empty.
|
|
|
|
|
request, response = await async_client.get('/api/flasher/configs')
|
|
|
|
|
assert response.status_code == HTTPStatus.OK
|
|
|
|
|
assert response.json['configurations'] == []
|
|
|
|
|
|
|
|
|
|
# Save a multi-part configuration (like a Meshtastic T-Deck).
|
|
|
|
|
body = dict(name='T-Deck MUI', erase_all=True, files=[
|
|
|
|
|
dict(path='software/firmware-t-deck-tft.bin', address='0x0', name='firmware-t-deck-tft.bin', size=100),
|
|
|
|
|
dict(path='software/littlefs-t-deck-tft.bin', address='0xc90000', name='littlefs-t-deck-tft.bin', size=200),
|
|
|
|
|
])
|
|
|
|
|
request, response = await async_client.post('/api/flasher/configs', json=body)
|
|
|
|
|
assert response.status_code == HTTPStatus.CREATED
|
|
|
|
|
|
|
|
|
|
# It is listed with its files and offsets.
|
|
|
|
|
request, response = await async_client.get('/api/flasher/configs')
|
|
|
|
|
configs = response.json['configurations']
|
|
|
|
|
assert len(configs) == 1
|
|
|
|
|
assert configs[0]['name'] == 'T-Deck MUI'
|
|
|
|
|
assert configs[0]['erase_all'] is True
|
|
|
|
|
assert [f['address'] for f in configs[0]['files']] == ['0x0', '0xc90000']
|
|
|
|
|
|
|
|
|
|
# It is persisted to flasher.yaml.
|
|
|
|
|
assert (test_directory / 'config/flasher.yaml').is_file()
|
|
|
|
|
|
|
|
|
|
# Saving the same name replaces it (still one configuration).
|
|
|
|
|
body2 = dict(name='T-Deck MUI', files=[dict(path='software/other.bin', address='0x0')])
|
|
|
|
|
request, response = await async_client.post('/api/flasher/configs', json=body2)
|
|
|
|
|
assert response.status_code == HTTPStatus.CREATED
|
|
|
|
|
request, response = await async_client.get('/api/flasher/configs')
|
|
|
|
|
configs = response.json['configurations']
|
|
|
|
|
assert len(configs) == 1
|
|
|
|
|
assert [f['path'] for f in configs[0]['files']] == ['software/other.bin']
|
|
|
|
|
|
|
|
|
|
# An empty name is rejected.
|
|
|
|
|
request, response = await async_client.post('/api/flasher/configs', json=dict(name=' ', files=[]))
|
|
|
|
|
assert response.status_code == HTTPStatus.BAD_REQUEST
|
|
|
|
|
|
|
|
|
|
# Delete it (name is URL-encoded).
|
|
|
|
|
request, response = await async_client.delete('/api/flasher/configs/T-Deck%20MUI')
|
|
|
|
|
assert response.status_code == HTTPStatus.NO_CONTENT
|
|
|
|
|
request, response = await async_client.get('/api/flasher/configs')
|
|
|
|
|
assert response.json['configurations'] == []
|
|
|
|
|
|
|
|
|
|
# Deleting a missing configuration 404s.
|
|
|
|
|
request, response = await async_client.delete('/api/flasher/configs/nope')
|
|
|
|
|
assert response.status_code == HTTPStatus.NOT_FOUND
|