Repository navigation
[fix] Add AppTest support for st.camera_input - #17459
Conversation
Testers can stage one JPEG photo the way the browser widget captures it.
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
✅ PR preview is ready!
|
|
The docs now say which shapes are rejected and that clear stages no photo.
Added Agent Docs
|
There was a problem hiding this comment.
Summary
This PR adds st.camera_input to the public AppTest API. at.camera_input returns a CameraInput node. .set_value(), .upload(), and .clear() stage one JPEG for the next .run(). .value is the committed UploadedFile or None. .resolution reports "480p", "720p", "1080p", or None. A filename that does not end in .jpg, a MIME type other than image/jpeg, a list, or non-bytes content raises AppTestError before the script runs. Staged bytes go through the existing in-memory upload registration path, so form submit and clear_on_submit behave like st.file_uploader and st.audio_input. The inspect-only example that used an untyped camera node now uses st.plotly_chart.
All three expected reviews completed: gpt-5.6-terra-xhigh, claude-opus-5-5-medium, and grok-4.7-xhigh. None failed to complete review. All three approve. They agree on product fit, correctness, test coverage, compatibility, security, performance, and external-test risk. The disagreements are optional wording and two small cleanups. Those are resolved below and do not block merge.
Product Alignment
This PR is user-facing for people writing AppTest tests. The production st.camera_input signature, capture behavior, and rendered UI stay as they are.
It is a small extension of the existing testing API, in the same shape as AudioInput from #17346, so it does not need a new product spec. The spec already merged on develop, specs/2026-05-19-camera-input-size/product-spec.md, defines resolution as None, "480p", "720p", or "1080p". .resolution reports those preset strings, mapped back from protobuf heights 480, 720, and 1080. The PR body does not claim a separate product approval; the behavior it describes matches the implementation.
All three reviews agree the surface follows existing AppTest file-widget conventions and the principles in specs/AGENTS.md:
- One photo, a
.jpgname, andimage/jpegmatch what the browser widget produces and whatCameraInputSerdeaccepts (.jpgonly). .upload()replaces the staged photo. A list, including a one-item list, is rejected, consistent withAudioInput..valuestaysUploadedFile | None, the typest.camera_inputreturns, and it updates only after.run().- Invalid input fails in the test call with an
AppTestErrorthat names the rejected filename or MIME type. - A photo inside a form stays uncommitted until that form is submitted.
No material product-alignment issues.
Code Quality
CameraInput follows AudioInput: parse one file, stage (file_id, filename, content, mime_type), rebuild file_uploader_state_value, and reuse the session-state UploadedFile when the test has not staged a change. AppTest._register_uploaded_files, _unset_value_marker, and parse_tree_from_messages include the new class, so form gating and clear_on_submit use the same path as the other file widgets. The resolution map is the inverse of _RESOLUTION_TO_HEIGHT in lib/streamlit/elements/widgets/camera_input.py. An unknown height becomes None. The public widget only writes 480, 720, and 1080.
The reviews agree the implementation is correct. They differ on two cleanups, both optional:
claude-opus-5-5-mediumsuggests sharing_get_files_to_register,_widget_state,value,upload, andclearwithAudioInputandFileUploader.gpt-5.6-terra-xhighandgrok-4.7-xhightreat the explicit widget class as the established AppTest pattern. That fits this PR:AudioInputlanded as its own class with the same structure. A shared helper is a reasonable later refactor if more single-file widgets appear.claude-opus-5-5-mediumsays theHasField("resolution_height")branch inCameraInput.__init__can be a single_CAMERA_RESOLUTION_BY_HEIGHT.get(proto.resolution_height).grok-4.7-xhighsaysHasFieldis required because an unset optional int reads as0. The field isoptional int32inCameraInput.proto. Unset reads as0, and0is not a key in the map, so.get()already returnsNonefor an omitted resolution, for an explicit0, and for any height other than 480, 720, or 1080. The branch and the direct lookup return the same value. The current code is correct; the shorter form is an optional simplification.
Performance
No production hot-path changes. There is no frontend code, and the backend widget, protobuf, session state, and WebSocket paths are untouched. The extra camera widgets in _register_uploaded_files run once per AppTest.run(), with the same per-file copy FileUploader and AudioInput already do inside the test runner. All three reviews agree. No benchmark coverage is warranted.
Test Coverage
The unit tests in lib/tests/streamlit/testing/element_tree_test.py match the audio_input suite and cover the new behavior:
- A JPEG commits on
.run(),.valuestays unchanged until then, and a second.upload()replaces the staged photo. .JPG/Image/JPEGis accepted and stored asimage/jpeg.enforce_filename_restrictionlowercases the name before the.jpgcheck, so the case-variant matches the backend.- The photo survives a later
.run()without firingon_changeagain, and.clear()removes it and fireson_change. .resolutionis"720p"when the script sets it andNonewhen the script omits it, including a sidebar widget..jpeg, a non-JPEG MIME type, a list, non-bytes content, and raw bytes raiseAppTestError.- A disabled widget rejects
.upload()and.clear(). - A form photo stays uncommitted until submit, and
clear_on_submitclears it on the next submit.
E2E coverage is unnecessary. AppTest runs the script in-process. Browser capture stays covered by e2e_playwright/st_camera_input_test.py. No typing test is added, which matches AudioInput; AppTest accessors are not covered under lib/tests/streamlit/typing. All three reviews agree this coverage is sufficient.
Backwards Compatibility
App scripts are unchanged. st.camera_input still returns UploadedFile | None.
at.camera_input is new. at.get("camera_input") now returns CameraInput where it used to return UnknownElement, and .set_value() stages a photo where it used to raise the inspect-only error. .click() still raises AppTestError. The in-repo inspect-only example moved to st.plotly_chart, which remains an UnknownElement. All three reviews treat this as additive. Tests outside this repo that pinned the old "not supported" string for camera_input would need the same fixture swap.
Security & Risk
No security concern. Test-supplied bytes stay in MemoryUploadedFileManager. The /mock/upload/... URLs are copied onto the proto and are not routes. There is no new dependency, endpoint, cookie, HTML rendering, or dynamic code execution. .jpg checks run before the script, and deserialize still calls enforce_filename_restriction. All three reviews agree. Regression risk is limited to the AppTest harness.
External test recommendation
- Recommend external_test: No
- Triggered categories: None
- Evidence:
lib/streamlit/testing/v1/element_tree.py:CameraInputbuilds an in-memoryFileUploaderStatefor the test runner. It does not change upload routes, cookies, WebSocket handshake, iframe/postMessage, asset serving, CORS, CSP, or browser storage.lib/streamlit/testing/v1/app_test.py:_register_uploaded_filespasses staged bytes to the existingMemoryUploadedFileManagerthroughLocalScriptRunner.register_file.lib/tests/streamlit/testing/element_tree_test.py: in-processAppTestcases only.
- Suggested external_test focus areas: None. Embedded, proxied, and SiS runs do not exercise this API. Existing camera-input e2e coverage remains the browser check.
- Confidence: High
- Assumptions and gaps: The diff is limited to the
AppTestharness and its unit tests. All three reviews reached the same recommendation.
Accessibility
No frontend or rendered UI changes. Accessibility is unchanged. All three reviews agree.
Readability
lib/streamlit/testing/v1/element_tree.py
CameraInput.value — claude-opus-5-5-medium proposed a rewrite so the docstring would lead with the committed UploadedFile. The docstring already opens with "The photo committed by the last run()." gpt-5.6-terra-xhigh and grok-4.7-xhigh found it consistent with AudioInput. Leave it.
_parse_camera_input_file MIME comment — grok-4.7-xhigh preferred AudioInput's longer "Normalize case-variants..." wording. The current comment already says the return is the browser MIME type for case-variants such as "Image/JPEG". The other two reviews did not flag it. The current sentence is enough.
CameraInput.__init__ resolution comment — grok-4.7-xhigh would keep HasField and rewrite the comment to say the check is required. That would document a distinction the map does not make: an unset field and a height of 0 both become None. The optional simplification is the inline comment. The current comment correctly describes proto presence.
lib/tests/streamlit/testing/element_tree_test.py
test_camera_input_captures_one_jpeg — claude-opus-5-5-medium called the docstring a long list of behaviors. It is one sentence: "One JPEG is committed on run, replaced by upload, kept across reruns, and cleared." That meets the brief-docstring guidance in lib/tests/AGENTS.md. gpt-5.6-terra-xhigh found the test names and docstrings clear. Leave it.
PR title and description
Title — [feature] Add AppTest support for st.camera_input stands alone and uses the [type] Description form. All three reviews accept it.
Description — The opening states the change. The bullets explain single-file JPEG validation, staging, and form submit. claude-opus-5-5-medium would rephrase "wiki queue P3.3" and mention the st.plotly_chart fixture swap. The backlog link already sits next to P3.3, which is the context grok-4.7-xhigh and gpt-5.6-terra-xhigh treat as sufficient, and the testing plan already points at element_tree_test.py. grok-4.7-xhigh would give .resolution its own bullet. Those values are already stated at the end of the first bullet. The description can stay as written.
Recommendations
- No change is required before merge.
- Optional: apply the inline suggestion and replace the
HasFieldbranch inCameraInput.__init__with a single map lookup. - Optional follow-up, outside this PR: shared helpers for the single-file upload widgets, if another widget of this shape is added. Two of the three reviews would leave
CameraInputas its own class.
Verdict
APPROVED: All three reviews approve, and the implementation matches the AudioInput testing API and the camera widget's single-JPEG contract, with unit coverage for commit, replace, persistence, validation, disabled widgets, and forms.
This is an automated AI review consolidated by grok-4.7-xhigh. Please verify the feedback and use your judgment.
This review also includes 2 inline comment(s) on specific code lines.
There was a problem hiding this comment.
Summary
This PR adds typed AppTest support for st.camera_input. Tests can stage, replace, and clear one JPEG, read the UploadedFile committed by the last .run(), and inspect resolution. The st.camera_input command and the browser widget are unchanged.
All four expected reviews completed: gpt-5.6-sol-high, claude-opus-5-5-high, gemini-3.8-flash-high, and grok-4.7-xhigh. Each verdict is APPROVED. No expected model failed to complete a review.
The implementation follows the AudioInput path already on develop:
CameraInputinlib/streamlit/testing/v1/element_tree.pyaccepts one.jpg/image/jpegphoto, exposesvalue,resolution,set_value(),upload(), andclear(), and buildsfile_uploader_state_value.AppTest.camera_inputandElementTree.camera_inputexpose the nodes._register_uploaded_filesregisters the bytes withMemoryUploadedFileManager._unset_value_markerincludesCameraInput, so form submit andclear_on_submituse the shared file-widget logic.- Unit tests cover commit versus staged value, persistence,
on_change, replacement (including.JPG/Image/JPEG), clear, disabled widgets, invalid payloads, and forms. The inspectable-only fixture moved fromcamera_inputtoplotly_chart.
The reviewers agreed this is a small, additive testing-API change. They differed only on optional maintainability and wording, covered below.
Product Alignment
The user-facing impact is the public streamlit.testing.v1 surface: at.camera_input and the CameraInput node. st.camera_input's signature, return type, and browser behavior stay the same.
No dedicated AppTest spec is required, and all four reviewers agreed. specs/2026-05-19-camera-input-size/product-spec.md, already on the base branch, defines resolution as "480p", "720p", "1080p", or omitted. CameraInput.resolution returns those literals, or None when the proto height is unset (protobuf returns 0 for an omitted optional int, and 0 is absent from the height map). Claude treated that spec as covering the widget's resolution values and the AppTest API as a separate, non-controversial extension. The other three cited the same spec for the exposed strings. Those readings agree: the spec approves the strings, and the testing API is an incremental extension of AudioInput and FileUploader (file_uploader, audio_input, pagination, and link_button landed the same way).
The surface matches the API principles in specs/AGENTS.md. upload() defaults the MIME type to image/jpeg. set_value(), clear(), and value match the neighboring upload widgets. A non-.jpg name, a non-JPEG MIME type, or a list fails in AppTest before the script run, which matches the browser capture and CameraInputSerde.enforce_filename_restriction(..., [".jpg"]). The PR body does not claim a separate product approval, and none is needed for this scope.
Code Quality
The integration points are complete and consistent with AudioInput:
parse_tree_from_messagesbuilds aCameraInputfor thecamera_inputproto (lib/streamlit/testing/v1/element_tree.pyaround line 3945).ElementTree.camera_input(around line 3146) andAppTest.camera_input(lib/streamlit/testing/v1/app_test.pyaround line 849) expose it._register_uploaded_files(around line 586) includes*self._tree.camera_input._unset_value_marker(around line 3672) treatsCameraInput._fileslikeAudioInputandFileUploader.
Typing follows the file: Self returns, proto types, and # ty: ignore[invalid-method-override] on set_value.
Claude flagged two duplication sites. Both are real:
_CAMERA_RESOLUTION_BY_HEIGHT(element_tree.pylines 1733–1737) inverts_RESOLUTION_TO_HEIGHTinlib/streamlit/elements/widgets/camera_input.py(lines 60–64). The three heights match today. A future resolution would read asNonein AppTest until this map is updated, and the current tests would still pass._get_files_to_registerand_widget_state(lines 1889–1941) repeat theAudioInputimplementations, so theFileUploaderStateloop now exists in three classes.
Gemini, GPT, and Grok treat that copy as the existing per-widget pattern and proportionate to the feature. That is the right call for merge: the class stays easy to follow, and every integration point is updated. Sharing the resolution map is the higher-value follow-up because drift fails silently. Extracting upload helpers can wait.
Performance
There is no production backend or frontend hot-path change. The new work runs only inside AppTest: one pass over camera widgets and the same in-memory registration st.file_uploader and st.audio_input already do, including a getvalue() copy when a committed photo is replayed on a later .run(). All four reviewers agreed. Performance coverage is not warranted.
Test Coverage
Unit coverage is sufficient. Playwright e2e coverage does not apply, because the change is an in-process AppTest simulation.
test_camera_input_captures_one_jpeg checks the empty state, a committed JPEG, that a second upload() leaves value pointing at the previous UploadedFile until .run(), persistence across a plain rerun, on_change incrementing once per committed change, .JPG / Image/JPEG normalization, repr, click() rejection, and clear(). test_camera_input_rejects_invalid_photos covers a .jpeg name, a non-JPEG MIME type, a list, string content, and raw bytes, and asserts value stays empty after the rejection. Disabled widgets and form submit / clear_on_submit have their own tests. "720p" and an omitted resolution are asserted.
Claude called out two small gaps. Checked against the implementation:
- Mixed-case MIME through
set_value()has no direct case.upload()callsset_value(), which calls_parse_camera_input_file, and the happy-path test already uploadsmime_type="Image/JPEG". That covers the shared parser. "480p"and"1080p"are unasserted. They use the same_CAMERA_RESOLUTION_BY_HEIGHT.getlookup as"720p".
A one-item list hits the same isinstance(file, list) branch as the two-item case the parametrize already runs. These are optional extras, and the other three reviewers treated the suite as sufficient.
Backwards Compatibility
App scripts are unchanged. at.camera_input is new. Nodes that previously parsed as UnknownElement now parse as CameraInput. .type stays "camera_input". .key, .label, and .proto still come from Widget. set_value() on a valid JPEG tuple now stages a photo. Invalid payloads still raise AppTestError. click() still raises. All four reviewers agreed this is an additive upgrade of an inspect-only node, with the same shape as the earlier AudioInput change.
Security & Risk
No new routes, auth, cookies, CORS, CSP, filesystem writes, or browser upload behavior. Photos stay in AppTest's MemoryUploadedFileManager and use the existing /mock/upload/test session id/… URLs. Filename and MIME checks limit staged values to one JPEG. CameraInputSerde still enforces .jpg when the script runs. Regression risk is low because the path mirrors the tested audio and file widgets. All four reviewers agreed.
External test recommendation
- Recommend external_test: No
- Triggered categories: None
- Evidence:
lib/streamlit/testing/v1/app_test.py: in-memory registration of camera photos withMemoryUploadedFileManager. No upload endpoint, route, transport, or external-host change.lib/streamlit/testing/v1/element_tree.py: local test representation and widget-state serialization. Bytes are attached tofile_uploader_state_valuethe same way asAudioInput.lib/tests/streamlit/testing/element_tree_test.py: in-processAppTestunit tests.
- Suggested external_test focus areas: None. Embedded and hosted runs do not execute this harness.
- Confidence: High. All four reviewers agreed, assuming
AppTestremains an in-process harness and production camera/upload infrastructure is unchanged on the base branch.
Accessibility
Not applicable. There are no frontend or rendered-UI changes. All four reviewers agreed.
Readability
GPT and Gemini found the code comments and docstrings clear. Claude and Grok proposed small rewrites. The current text supports the rewrites as optional polish.
lib/streamlit/testing/v1/element_tree.py
- Line 1764:
# Return the browser MIME type for case-variants such as "Image/JPEG".The siblingAudioInputcomment states the rule. Proposed:# Normalize case-variants such as "Image/JPEG" to the MIME type the browser widget reports. - Lines 1787–1788 and
set_value(lines 1836–1837): the checks use.lower(), and a mixed-case MIME is stored asimage/jpeg. The docs read as exact matches. Add "(any case)" on the filename and MIME type, and say the stored MIME type isimage/jpeg. - Lines 1822–1823: the comment leads with the proto default. Proposed:
# Omitted and unknown capture heights are both None. Protobuf returns 0 for an unset optional int, and 0 is not 480, 720, or 1080. clear(line 1880):"""Clear the photo."""omits that the clear is staged. Proposed:Stage no photo. ``value`` updates on the next ``run()``.
lib/streamlit/testing/v1/app_test.py and lib/tests/streamlit/testing/element_tree_test.py
Names, the updated upload-registration docstring, and the test docstrings are clear. No rewrite.
PR title and description
The four reviews described the title as [feature] Add AppTest support for st.camera_input and accepted it. The current title is [fix] Add AppTest support for st.camera_input, and the PR is labeled change:bugfix. This adds a public testing API. [feature] and change:feature match the types in wiki/pull-requests.md. Either title fits the 63-character limit.
The description leads with the user-visible behavior and covers single-JPEG validation and form commit. Two wording fixes, both optional:
- The first bullet says
at.camera_input.value.at.camera_inputis aWidgetList. The accessors areat.camera_input[0].valueandat.camera_input(key="...").value. Gemini flagged this, and theAppTest.camera_inputdocstring already shows both forms. - That same bullet packs value, staging, clear, and resolution into one sentence, and "wiki queue P3.3" is an internal id. Split those behaviors, and link the App testing fixes backlog without the queue id.
GPT found the description adequate as written. The edits above are optional.
Recommendations
- Optionally derive
_CAMERA_RESOLUTION_BY_HEIGHTfrom the widget resolution map, or share one constant, so a new resolution cannot read asNonein AppTest. - Optionally extract the shared single-file upload helpers used by
AudioInput,CameraInput, andFileUploader. This can be a follow-up. - Optionally apply the docstring, comment, title/label, and PR-description edits above. If the list-rejection message is rewritten to name the
(filename, content, mime_type)tuple, update the unit-test match and the parallelAudioInputmessage.
Verdict
APPROVED: All four reviews approve this additive, well-tested AppTest extension, and the remaining notes are optional maintainability and wording follow-ups.
This is an automated AI review consolidated by grok-4.7-xhigh. Please verify the feedback and use your judgment.
This review also includes 5 inline comment(s) on specific code lines.
|
|
||
|
|
||
| _CAMERA_INPUT_MIME: Final = "image/jpeg" | ||
| _CAMERA_RESOLUTION_BY_HEIGHT: Final[dict[int, str]] = { |
There was a problem hiding this comment.
suggestion: _CAMERA_RESOLUTION_BY_HEIGHT is a hand-copied inverse of _RESOLUTION_TO_HEIGHT in lib/streamlit/elements/widgets/camera_input.py. The three heights match today, but a new resolution such as "1440p" would make CameraInput.resolution return None with no test failure. Derive it, for example {height: label for label, height in camera_input._RESOLUTION_TO_HEIGHT.items()}, or share one non-private constant.
| return filename, bytes(content), _CAMERA_INPUT_MIME | ||
| if isinstance(file, list): | ||
| raise AppTestError( | ||
| "st.camera_input captures one photo. A list is not accepted." |
There was a problem hiding this comment.
suggestion: Name the shape that works. A one-item list is the FileUploader.set_value habit, and this branch only says a list is rejected. Prefer st.camera_input captures one photo. Pass one (filename, content, mime_type) tuple, not a list. If the text changes, update the "list is not accepted" match in test_camera_input_rejects_invalid_photos, and keep AudioInput's matching message in sync.
| ] | ||
|
|
||
| @property | ||
| def _widget_state(self) -> WidgetState: |
There was a problem hiding this comment.
suggestion: _get_files_to_register and _widget_state copy the AudioInput versions, so the FileUploaderState proto loop now lives in AudioInput, CameraInput, and FileUploader. A follow-up can extract helpers such as _committed_single_upload(root, widget_id) and _file_uploader_widget_state(widget_id, files) so a mock-URL or file-info fix lands once.
| ``.upload()`` replaces any staged photo. Unlike ``FileUploader``, this | ||
| widget does not accept a list or append files. | ||
|
|
||
| ``AppTest`` accepts a photo only when: |
There was a problem hiding this comment.
nitpick: These bullets repeat the "one JPEG" rule above, and they read as exact matches even though the checks are case-insensitive (.JPG and Image/JPEG are accepted and the MIME type is stored as image/jpeg). Lead with the rule: the photo is one (filename, content, mime_type) tuple; the filename must end in .jpg (any case); the MIME type must be image/jpeg (any case). .upload() replaces any staged photo, and unlike FileUploader this widget does not accept a list. resolution is "480p", "720p", "1080p", or None when the script omits it.
| f"st.camera_input photos use MIME type {_CAMERA_INPUT_MIME!r}. " | ||
| f"Got {mime_type!r}." | ||
| ) | ||
| # Return the browser MIME type for case-variants such as "Image/JPEG". |
There was a problem hiding this comment.
nitpick: "Return the browser MIME type" reads as a lookup. Match the AudioInput comment: # Normalize case-variants such as "Image/JPEG" to the MIME type the browser widget reports.
There was a problem hiding this comment.
Auto-approved by AI final review (workflow run).
There was a problem hiding this comment.
Summary
AppTest can drive st.camera_input the same way it drives st.audio_input. at.camera_input returns a typed CameraInput: tests stage one JPEG with .set_value() or .upload(), clear it with .clear(), and read the UploadedFile committed by the last .run(). .resolution reports "480p", "720p", "1080p", or None. Invalid filenames, MIME types, and lists raise AppTestError. A photo inside a form stays uncommitted until submit, including clear_on_submit.
All four expected reviews completed (gpt-5.6-sol-high, claude-opus-5-5-high, gemini-3.8-flash-high, grok-4.7-xhigh). None failed to complete review. All four approve. They agree the change is additive, limited to the in-process test harness, and consistent with the merged camera-resolution spec and the existing file-widget testing API.
Product Alignment
This PR is user-facing for authors of streamlit.testing.v1.AppTest. It does not change st.camera_input in running apps.
The merged spec specs/2026-05-19-camera-input-size/product-spec.md defines resolution as None, "480p", "720p", or "1080p". .resolution maps the protobuf heights 480, 720, and 1080 back to those labels, matching _RESOLUTION_TO_HEIGHT in camera_input.py. The PR body states the testing contract and does not claim a separate product approval. AppTest support for an existing widget is a small enhancement, so a new product spec is not required.
The surface matches AudioInput and the API principles in specs/AGENTS.md:
- The common call is
upload("shot.jpg", jpeg_bytes), with MIME type defaulting toimage/jpeg. .upload()replaces the staged photo, because the widget holds one file.FileUploader.upload()still appends, which is the right split for a multi-file widget.- A non-
.jpgname, a non-image/jpegMIME type, and any list fail immediately withAppTestError. The browser widget emits oneimage/jpegwhose name ends in.jpg(CameraInput.tsx), andCameraInputSerdeenforces.jpgon the way back in..jpegis rejected in both places. .resolutionis read-only. AppTest does not resize the staged bytes, which matches how.sample_rateis exposed for audio and how the spec treats the returned image as whatever the camera actually captured.
Reviewers agreed there is no material product or API mismatch.
Code Quality
CameraInput is wired through the three existing extension points: element parsing, _register_uploaded_files, and _unset_value_marker. Form gating, file-id reuse across reruns, and disabled-widget checks stay on that shared path.
Reviewers differed on the copy of AudioInput (about 200 lines in _parse_camera_input_file and CameraInput). One review suggested a private _SingleFileInputWidget base class. The copy should stay. lib/AGENTS.md prefers composition over inheritance, and a new abstraction for two widgets would add more structure than this PR needs. A shared helper can wait until a third single-file widget appears.
Reviewers also differed on resolution lookup. One review suggested proto.HasField("resolution_height"), matching AudioInput.sample_rate. The dict lookup is the right check here. sample_rate needs HasField because an unset proto field reads as 0, and 0 is a different signal from sample_rate=None. For camera, an omitted optional int32 also reads as 0, and 0 is outside {480, 720, 1080}, so omitted and unknown heights both become None. That matches the latest commit, which simplified this lookup on purpose. The public presets still round-trip.
enforce_filename_restriction lowercases extensions, so the test upload of other.JPG is consistent with the backend.
Performance
No production hot-path impact. The new work runs inside AppTest when a test reruns a script that contains a camera widget. Persisting a photo copies that file's bytes once via UploadedFile.getvalue(), the same as audio and st.file_uploader. Widget parsing adds one camera_input branch. No extra renders, remounts, or full-state scans on the app runtime. Benchmark coverage is not warranted.
Test Coverage
Unit coverage in lib/tests/streamlit/testing/element_tree_test.py matches the audio tests and is sufficient to merge:
- Commit, session state,
on_change, upload replacement (including.JPGandImage/JPEG), persistence across a plain.run(), repr, rejected.click(), and.clear(). - Parametrized rejection of
.jpeg, the wrong MIME type, a list, non-bytes content, and raw bytes. Any list is rejected, so a one-item list hits the same branch. - Disabled widgets, form submit, and
clear_on_submit. - Sidebar lookup for an omitted resolution.
One review asked to split test_camera_input_captures_one_jpeg and to annotate the nested script() with -> None. The long test should stay. It mirrors test_audio_input_records_one_wav in the same file, and lib/tests/AGENTS.md asks for targeted checks rather than a larger matrix. The nested script() return annotation matches that audio test; the form test already uses -> None, as the audio form test does.
Replacing st.camera_input with st.plotly_chart in the inspectable-element test keeps an UnknownElement assertion. Playwright coverage is unnecessary. This change never renders the camera widget, and existing camera e2e tests still cover browser capture.
Backwards Compatibility
st.camera_input in running apps is unchanged.
For AppTest, a camera widget used to be an UnknownElement. Inspecting .value, .label, and proto fields such as resolution_height still works. .set_value() and .upload() now stage a photo where they previously raised. .click() still raises AppTestError, with the typed-widget message. Tests that asserted the old "does not implement interactions" string for camera_input need the new message; this PR updates the in-repo test. CameraInput is reached through at.camera_input, the same way AudioInput is reached, and testing.v1.__all__ stays AppTest and AppTestError.
Security & Risk
Staged bytes stay in MemoryUploadedFileManager under the existing AppTest session id. The filename is metadata, not a filesystem path. Widget state uses the same mock /mock/upload/test session id/{file_id} URLs as audio and file uploads. No new route, cookie, websocket, HTML, subprocess, or dependency. CameraInputSerde still rejects names that do not end in .jpg if a payload reaches the widget.
External test recommendation
- Recommend external_test: No
- Triggered categories: None
- Evidence:
lib/streamlit/testing/v1/element_tree.py: in-processCameraInputwidget state and JPEG checks only. Mock file URLs are copied from the existing audio and file-uploader AppTest path. Upload endpoints, CSRF wrapping, and download attributes are unchanged.lib/streamlit/testing/v1/app_test.py:_register_uploaded_filesalso registerscamera_inputbytes withMemoryUploadedFileManager. No server route, websocket, embedding, or origin behavior.lib/tests/streamlit/testing/element_tree_test.py: AppTest unit tests only.
- Suggested external_test focus areas:
- None. Externally hosted, proxied, embedded, and SiS deployments do not exercise this API.
- Confidence: High
- Assumptions and gaps: Form commit,
clear_on_submit, and file-id reuse use the same helpers asAudioInput, which is already ondevelop. Production camera capture and upload code are unchanged.
Accessibility
No frontend or rendered-UI change. The camera widget's browser behavior is unchanged.
Readability
Reviewers agreed the title [fix] Add AppTest support for st.camera_input stands alone and fits the title pattern. They differed on the description. The body already leads with the capability and explains the single-JPEG and form rules. The one clear improvement is dropping the internal queue id.
PR description
- Issue: "wiki queue P3.3" is backlog jargon. The link already identifies the item.
- Proposed rewrite:
Implements the camera input item from the [App testing fixes backlog](https://github.com/streamlit/streamlit/wiki/2026-07-22-app-testing-fixes).
lib/streamlit/testing/v1/element_tree.py
-
CameraInputclass docstring, line 1781. "The photo is one JPEG file,(filename, content, mime_type)" can be read as the shape of.value..valueis anUploadedFile. Proposed opening:"""A representation of ``st.camera_input``. Stage one JPEG as ``(filename, content, mime_type)``. ``.value`` returns the ``UploadedFile`` committed by the last ``.run()``, and ``.upload()`` replaces any staged photo. Unlike ``FileUploader``, this widget does not accept a list or append files.
-
CameraInput.__init__, lines 1822–1824. The comment opens with proto mechanics. Lead with the rule:# None means the script omitted resolution. An unset optional int reads as # 0, and 0 is not a mapped height, so an unknown height is None too. self.resolution = _CAMERA_RESOLUTION_BY_HEIGHT.get(proto.resolution_height)
The remaining comments, names, and docstrings match AudioInput and are clear. The "or None to clear" text in _parse_camera_input_file matches the audio helper; set_value already handles None before that helper runs.
Recommendations
- No merge-blocking changes. The docstring and PR-description rewrites above are optional.
- Optional follow-up: add
CameraInputto the public AppTest API reference if that page is maintained outside this repo. The class docstring already documents the testing contract.
Verdict
APPROVED: AppTest camera support matches the audio-input testing API, preserves st.camera_input for running apps, and has unit coverage for commit, rejection, disabled widgets, and forms.
This is an automated AI review consolidated by grok-4.7-xhigh. Please verify the feedback and use your judgment.
This review also includes 1 inline comment(s) on specific code lines.
| cam = at.camera_input(key="cam") | ||
| assert cam.value is None | ||
| assert cam.label == "Cam" | ||
| assert cam.resolution == "720p" |
There was a problem hiding this comment.
suggestion: Parameterize this assertion over "480p", "720p", and "1080p". .resolution is a closed public map of those three heights, and this test only locks 720p plus the omitted None case.
There was a problem hiding this comment.
Auto-approved by AI final review (workflow run).
Describe your changes
AppTestcan stage one JPEG photo forst.camera_inputand read the file the script receives.at.camera_input.valueis the photo from the last.run()(UploadedFileorNone)..set_value()and.upload()stage the next photo;.upload()replaces any staged photo because the widget holds one file..clear()stages no photo..resolutionis"480p","720p","1080p", orNonewhen the script omits it..jpg, a MIME type other thanimage/jpeg, or a list of photos raisesAppTestError. The browser widget only produces one JPEG, so a one-item list is rejected too.Implements wiki queue P3.3 from the App testing fixes backlog.
GitHub Issue Link (if applicable)
N/A
Testing Plan
lib/tests/streamlit/testing/element_tree_test.pycovers a JPEG photo, persistence across reruns,on_change, disabled widgets, invalid files, and form submit /clear_on_submit.Contribution License Agreement
By submitting this pull request you agree that all contributions to this project are made under the Apache 2.0 license.