clicked-evidence 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- clicked_evidence-0.1.0/LICENSE +21 -0
- clicked_evidence-0.1.0/PKG-INFO +163 -0
- clicked_evidence-0.1.0/README.md +142 -0
- clicked_evidence-0.1.0/clicked/__init__.py +4 -0
- clicked_evidence-0.1.0/clicked/capture.py +142 -0
- clicked_evidence-0.1.0/clicked_evidence.egg-info/PKG-INFO +163 -0
- clicked_evidence-0.1.0/clicked_evidence.egg-info/SOURCES.txt +12 -0
- clicked_evidence-0.1.0/clicked_evidence.egg-info/dependency_links.txt +1 -0
- clicked_evidence-0.1.0/clicked_evidence.egg-info/requires.txt +1 -0
- clicked_evidence-0.1.0/clicked_evidence.egg-info/top_level.txt +1 -0
- clicked_evidence-0.1.0/pyproject.toml +45 -0
- clicked_evidence-0.1.0/setup.cfg +4 -0
- clicked_evidence-0.1.0/tests/test_capture.py +172 -0
- clicked_evidence-0.1.0/tests/test_capture_live.py +164 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ritish Saini
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clicked-evidence
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Prove what one browser interaction actually did -- every network request it made, and optionally what changed on a local backing store.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/MaXiMo000/clicked
|
|
7
|
+
Project-URL: Source, https://github.com/MaXiMo000/clicked
|
|
8
|
+
Project-URL: Issues, https://github.com/MaXiMo000/clicked/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/MaXiMo000/clicked/releases
|
|
10
|
+
Keywords: browser,playwright,testing,evidence,provenance,e2e
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Testing
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: receipt-evidence>=0.1.1
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# clicked
|
|
23
|
+
|
|
24
|
+
[](https://github.com/MaXiMo000/clicked/actions/workflows/ci.yml)
|
|
25
|
+
|
|
26
|
+
**Prove what one browser interaction actually did -- every network
|
|
27
|
+
request it made, and optionally what changed on a local backing store.**
|
|
28
|
+
|
|
29
|
+
[`receipt`](https://github.com/MaXiMo000/receipt) proves what a shell
|
|
30
|
+
command touched. [`custody`](https://github.com/MaXiMo000/custody) proves
|
|
31
|
+
what an AI agent's tool call touched. `clicked` proves what a *click*
|
|
32
|
+
touched -- the same "verified execution, not just self-report" idea, one
|
|
33
|
+
layer up: a browser-automation test that asserts "clicking Save sends the
|
|
34
|
+
right request" is trusting the test's own assumption unless something
|
|
35
|
+
independently recorded what the browser actually sent.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from clicked import capture
|
|
39
|
+
|
|
40
|
+
with capture(page, task="click save button") as c:
|
|
41
|
+
page.click("#save")
|
|
42
|
+
page.wait_for_timeout(200)
|
|
43
|
+
|
|
44
|
+
print(c.to_dict())
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"providence_version": 1,
|
|
50
|
+
"tool": "clicked",
|
|
51
|
+
"payload": {
|
|
52
|
+
"task": "click save button",
|
|
53
|
+
"seconds": 0.324,
|
|
54
|
+
"requests": [
|
|
55
|
+
{"url": "http://127.0.0.1:60672/api/save", "method": "POST",
|
|
56
|
+
"post_data": "{\"name\":\"ada\"}", "status": 200, "failure": null}
|
|
57
|
+
],
|
|
58
|
+
"raised": null
|
|
59
|
+
},
|
|
60
|
+
"sha256": "ed727efcdccbc..."
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
(Real output, from `tests/test_capture_live.py` -- a real headless
|
|
65
|
+
Chromium, clicking a real button, against a real local HTTP server.)
|
|
66
|
+
|
|
67
|
+
## Install
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
pip install clicked-evidence
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
For once, no PyPI name-squatting to work around -- both `clicked` and
|
|
74
|
+
`clicked-evidence` were actually free.
|
|
75
|
+
|
|
76
|
+
## Use
|
|
77
|
+
|
|
78
|
+
`capture()` takes any Playwright `Page` object and a `task` description.
|
|
79
|
+
Everything inside the `with` block is one interaction:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from clicked import capture
|
|
83
|
+
|
|
84
|
+
with capture(page, task="delete account button") as c:
|
|
85
|
+
page.click("#delete-account-button")
|
|
86
|
+
page.wait_for_load_state("networkidle")
|
|
87
|
+
|
|
88
|
+
c.write("receipt.json") # a Providence bundle -- see below
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`c.requests` is every network request/response pair that happened during
|
|
92
|
+
the block (URL, method, POST body, status, or a `failure` reason if the
|
|
93
|
+
request never got a response at all). This is what was actually sent and
|
|
94
|
+
actually came back -- not what the page's own JavaScript claims it sent.
|
|
95
|
+
|
|
96
|
+
## Redaction
|
|
97
|
+
|
|
98
|
+
`url` and `post_data` are swept through
|
|
99
|
+
[`receipt.redact`](https://github.com/MaXiMo000/receipt) before being
|
|
100
|
+
stored -- a real interaction routinely carries a credential in a query
|
|
101
|
+
string (`?api_key=...`) or a form/JSON POST body (`{"password": "..."}`),
|
|
102
|
+
and a receipt is meant to be kept and handed to someone else as evidence,
|
|
103
|
+
not a second place that credential now lives. Same regex-based, best-
|
|
104
|
+
effort sweep `receipt` and `custody` already use, not exhaustive -- see
|
|
105
|
+
receipt's own README for what it doesn't catch.
|
|
106
|
+
|
|
107
|
+
### Watching the filesystem too
|
|
108
|
+
|
|
109
|
+
Pass `watch_dir` to also snapshot-diff a local directory before and after
|
|
110
|
+
the interaction, reusing `receipt.snapshot`'s real `snapshot()`/`diff()`
|
|
111
|
+
core directly (not reimplemented):
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
with capture(page, task="click save button", watch_dir="./data") as c:
|
|
115
|
+
page.click("#save")
|
|
116
|
+
page.wait_for_timeout(200)
|
|
117
|
+
|
|
118
|
+
print(c.result["changes"]) # {"added": ["saved.json"], "modified": [], ...}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
This closes the loop from *client* interaction to *backend* side effect --
|
|
122
|
+
proving the click didn't just send a request that returned 200, but that
|
|
123
|
+
a real file actually landed on disk because of it. `watch_dir=None` (the
|
|
124
|
+
default) skips this step entirely; no extra install needed either way.
|
|
125
|
+
|
|
126
|
+
### Why no CLI
|
|
127
|
+
|
|
128
|
+
Every other tool in this portfolio is a CLI because each one wraps a
|
|
129
|
+
command, a file, or a config a human or CI job invokes directly. `clicked`
|
|
130
|
+
instruments one interaction inside a caller's *own* Playwright script --
|
|
131
|
+
there's nothing to invoke from a shell that isn't already that script.
|
|
132
|
+
The receipt itself is a Providence bundle either way (`c.to_dict()` /
|
|
133
|
+
`c.write(path)`), so it plugs into the same evidence pipeline as
|
|
134
|
+
everything else in this portfolio without needing its own entry point.
|
|
135
|
+
|
|
136
|
+
## Playwright is never imported here
|
|
137
|
+
|
|
138
|
+
`capture()` only calls `.on()` and `.remove_listener()` on whatever object
|
|
139
|
+
you pass it -- duck-typed, not a hard dependency on Playwright or any
|
|
140
|
+
particular version of it. `receipt-evidence` is the one real dependency
|
|
141
|
+
this package has -- a hard one, not an extra, because redaction (above)
|
|
142
|
+
is part of the core network-capture path, not an opt-in feature; it's
|
|
143
|
+
also itself dependency-free, so this stays a light install either way.
|
|
144
|
+
|
|
145
|
+
## Tests
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
pip install -e "."
|
|
149
|
+
python tests/test_capture.py # pure logic, a fake page object, no browser needed
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
pip install -e "." playwright
|
|
154
|
+
python -m playwright install chromium
|
|
155
|
+
python tests/test_capture_live.py # real Chromium, real HTTP server, real network + filesystem effects
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
17 tests total (13 + 4). The live suite is the one that actually matters
|
|
159
|
+
for a package whose whole point is "did this really happen" -- it skips
|
|
160
|
+
cleanly, rather than failing, if Playwright or its browser binary isn't
|
|
161
|
+
installed.
|
|
162
|
+
|
|
163
|
+
MIT licensed.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# clicked
|
|
2
|
+
|
|
3
|
+
[](https://github.com/MaXiMo000/clicked/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
**Prove what one browser interaction actually did -- every network
|
|
6
|
+
request it made, and optionally what changed on a local backing store.**
|
|
7
|
+
|
|
8
|
+
[`receipt`](https://github.com/MaXiMo000/receipt) proves what a shell
|
|
9
|
+
command touched. [`custody`](https://github.com/MaXiMo000/custody) proves
|
|
10
|
+
what an AI agent's tool call touched. `clicked` proves what a *click*
|
|
11
|
+
touched -- the same "verified execution, not just self-report" idea, one
|
|
12
|
+
layer up: a browser-automation test that asserts "clicking Save sends the
|
|
13
|
+
right request" is trusting the test's own assumption unless something
|
|
14
|
+
independently recorded what the browser actually sent.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from clicked import capture
|
|
18
|
+
|
|
19
|
+
with capture(page, task="click save button") as c:
|
|
20
|
+
page.click("#save")
|
|
21
|
+
page.wait_for_timeout(200)
|
|
22
|
+
|
|
23
|
+
print(c.to_dict())
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"providence_version": 1,
|
|
29
|
+
"tool": "clicked",
|
|
30
|
+
"payload": {
|
|
31
|
+
"task": "click save button",
|
|
32
|
+
"seconds": 0.324,
|
|
33
|
+
"requests": [
|
|
34
|
+
{"url": "http://127.0.0.1:60672/api/save", "method": "POST",
|
|
35
|
+
"post_data": "{\"name\":\"ada\"}", "status": 200, "failure": null}
|
|
36
|
+
],
|
|
37
|
+
"raised": null
|
|
38
|
+
},
|
|
39
|
+
"sha256": "ed727efcdccbc..."
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
(Real output, from `tests/test_capture_live.py` -- a real headless
|
|
44
|
+
Chromium, clicking a real button, against a real local HTTP server.)
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
pip install clicked-evidence
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
For once, no PyPI name-squatting to work around -- both `clicked` and
|
|
53
|
+
`clicked-evidence` were actually free.
|
|
54
|
+
|
|
55
|
+
## Use
|
|
56
|
+
|
|
57
|
+
`capture()` takes any Playwright `Page` object and a `task` description.
|
|
58
|
+
Everything inside the `with` block is one interaction:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from clicked import capture
|
|
62
|
+
|
|
63
|
+
with capture(page, task="delete account button") as c:
|
|
64
|
+
page.click("#delete-account-button")
|
|
65
|
+
page.wait_for_load_state("networkidle")
|
|
66
|
+
|
|
67
|
+
c.write("receipt.json") # a Providence bundle -- see below
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`c.requests` is every network request/response pair that happened during
|
|
71
|
+
the block (URL, method, POST body, status, or a `failure` reason if the
|
|
72
|
+
request never got a response at all). This is what was actually sent and
|
|
73
|
+
actually came back -- not what the page's own JavaScript claims it sent.
|
|
74
|
+
|
|
75
|
+
## Redaction
|
|
76
|
+
|
|
77
|
+
`url` and `post_data` are swept through
|
|
78
|
+
[`receipt.redact`](https://github.com/MaXiMo000/receipt) before being
|
|
79
|
+
stored -- a real interaction routinely carries a credential in a query
|
|
80
|
+
string (`?api_key=...`) or a form/JSON POST body (`{"password": "..."}`),
|
|
81
|
+
and a receipt is meant to be kept and handed to someone else as evidence,
|
|
82
|
+
not a second place that credential now lives. Same regex-based, best-
|
|
83
|
+
effort sweep `receipt` and `custody` already use, not exhaustive -- see
|
|
84
|
+
receipt's own README for what it doesn't catch.
|
|
85
|
+
|
|
86
|
+
### Watching the filesystem too
|
|
87
|
+
|
|
88
|
+
Pass `watch_dir` to also snapshot-diff a local directory before and after
|
|
89
|
+
the interaction, reusing `receipt.snapshot`'s real `snapshot()`/`diff()`
|
|
90
|
+
core directly (not reimplemented):
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
with capture(page, task="click save button", watch_dir="./data") as c:
|
|
94
|
+
page.click("#save")
|
|
95
|
+
page.wait_for_timeout(200)
|
|
96
|
+
|
|
97
|
+
print(c.result["changes"]) # {"added": ["saved.json"], "modified": [], ...}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
This closes the loop from *client* interaction to *backend* side effect --
|
|
101
|
+
proving the click didn't just send a request that returned 200, but that
|
|
102
|
+
a real file actually landed on disk because of it. `watch_dir=None` (the
|
|
103
|
+
default) skips this step entirely; no extra install needed either way.
|
|
104
|
+
|
|
105
|
+
### Why no CLI
|
|
106
|
+
|
|
107
|
+
Every other tool in this portfolio is a CLI because each one wraps a
|
|
108
|
+
command, a file, or a config a human or CI job invokes directly. `clicked`
|
|
109
|
+
instruments one interaction inside a caller's *own* Playwright script --
|
|
110
|
+
there's nothing to invoke from a shell that isn't already that script.
|
|
111
|
+
The receipt itself is a Providence bundle either way (`c.to_dict()` /
|
|
112
|
+
`c.write(path)`), so it plugs into the same evidence pipeline as
|
|
113
|
+
everything else in this portfolio without needing its own entry point.
|
|
114
|
+
|
|
115
|
+
## Playwright is never imported here
|
|
116
|
+
|
|
117
|
+
`capture()` only calls `.on()` and `.remove_listener()` on whatever object
|
|
118
|
+
you pass it -- duck-typed, not a hard dependency on Playwright or any
|
|
119
|
+
particular version of it. `receipt-evidence` is the one real dependency
|
|
120
|
+
this package has -- a hard one, not an extra, because redaction (above)
|
|
121
|
+
is part of the core network-capture path, not an opt-in feature; it's
|
|
122
|
+
also itself dependency-free, so this stays a light install either way.
|
|
123
|
+
|
|
124
|
+
## Tests
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
pip install -e "."
|
|
128
|
+
python tests/test_capture.py # pure logic, a fake page object, no browser needed
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
pip install -e "." playwright
|
|
133
|
+
python -m playwright install chromium
|
|
134
|
+
python tests/test_capture_live.py # real Chromium, real HTTP server, real network + filesystem effects
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
17 tests total (13 + 4). The live suite is the one that actually matters
|
|
138
|
+
for a package whose whole point is "did this really happen" -- it skips
|
|
139
|
+
cleanly, rather than failing, if Playwright or its browser binary isn't
|
|
140
|
+
installed.
|
|
141
|
+
|
|
142
|
+
MIT licensed.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Prove what one browser interaction actually did -- not just what
|
|
2
|
+
devtools show changed in the DOM, but every network request it made, and
|
|
3
|
+
optionally what changed on a local filesystem/backing store. The same
|
|
4
|
+
"verified execution, not just self-report" idea `receipt` proves for a
|
|
5
|
+
shell command and `custody` proves for an AI agent's tool call, applied
|
|
6
|
+
here to a click, a form submit, any one interaction with a real page.
|
|
7
|
+
|
|
8
|
+
Works with any Playwright `Page` object. Playwright itself is never
|
|
9
|
+
imported here -- everything is duck-typed against the object passed in
|
|
10
|
+
(`.on()`, `.remove_listener()`), so this package has zero hard dependency
|
|
11
|
+
on which version of Playwright (or, in principle, another automation
|
|
12
|
+
library exposing the same two methods) the caller happens to be using.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
from receipt.redact import redact
|
|
21
|
+
from receipt.snapshot import diff as _diff
|
|
22
|
+
from receipt.snapshot import snapshot as _snapshot
|
|
23
|
+
|
|
24
|
+
# receipt-evidence is a hard dependency (see pyproject.toml) precisely
|
|
25
|
+
# because of redact() above: a captured URL or POST body can carry a
|
|
26
|
+
# real credential (an API key in a query string, a password in a form
|
|
27
|
+
# submit), and that's not something a caller should have to opt into
|
|
28
|
+
# safety for by installing an extra. snapshot()/diff() ride along on the
|
|
29
|
+
# same dependency for the optional watch_dir feature.
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _safe(getter):
|
|
33
|
+
"""A Playwright accessor can itself raise (a request object's fields
|
|
34
|
+
aren't always populated depending on when in its lifecycle it's read)
|
|
35
|
+
-- never let reading one piece of metadata lose the rest of the
|
|
36
|
+
record."""
|
|
37
|
+
try:
|
|
38
|
+
return getter()
|
|
39
|
+
except Exception: # noqa: BLE001
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Capture:
|
|
44
|
+
"""One receipt, for one browser interaction.
|
|
45
|
+
|
|
46
|
+
with capture(page, task="delete account button") as c:
|
|
47
|
+
page.click("#delete-account-button")
|
|
48
|
+
page.wait_for_load_state("networkidle")
|
|
49
|
+
print(c.to_dict())
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, page, task: str, watch_dir: str | None = None):
|
|
53
|
+
self.page = page
|
|
54
|
+
self.task = task
|
|
55
|
+
self.watch_dir = watch_dir
|
|
56
|
+
self.requests: list[dict] = []
|
|
57
|
+
self.result: dict | None = None
|
|
58
|
+
self._started: float | None = None
|
|
59
|
+
self._before_snapshot: dict | None = None
|
|
60
|
+
|
|
61
|
+
def _on_response(self, response) -> None:
|
|
62
|
+
# url and post_data are redacted -- a real interaction can easily
|
|
63
|
+
# carry an API key in a query string or a password in a form
|
|
64
|
+
# submit, and a receipt is meant to be kept and handed to someone
|
|
65
|
+
# else as evidence, not a second place that credential now lives.
|
|
66
|
+
req = response.request
|
|
67
|
+
self.requests.append({
|
|
68
|
+
"url": redact(_safe(lambda: req.url)),
|
|
69
|
+
"method": _safe(lambda: req.method),
|
|
70
|
+
"post_data": redact(_safe(lambda: req.post_data)),
|
|
71
|
+
"resource_type": _safe(lambda: req.resource_type),
|
|
72
|
+
"status": _safe(lambda: response.status),
|
|
73
|
+
"failure": None,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
def _on_request_failed(self, request) -> None:
|
|
77
|
+
# A request that never got a response at all (DNS failure, aborted,
|
|
78
|
+
# blocked) -- recorded separately from _on_response so a network
|
|
79
|
+
# error during the interaction is on the record too, not silently
|
|
80
|
+
# absent because there was never a response to hang it off of.
|
|
81
|
+
self.requests.append({
|
|
82
|
+
"url": redact(_safe(lambda: request.url)),
|
|
83
|
+
"method": _safe(lambda: request.method),
|
|
84
|
+
"post_data": redact(_safe(lambda: request.post_data)),
|
|
85
|
+
"resource_type": _safe(lambda: request.resource_type),
|
|
86
|
+
"status": None,
|
|
87
|
+
"failure": _safe(lambda: request.failure),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
def __enter__(self) -> Capture:
|
|
91
|
+
self._started = time.time()
|
|
92
|
+
if self.watch_dir and _snapshot:
|
|
93
|
+
self._before_snapshot = _snapshot(self.watch_dir)
|
|
94
|
+
self.page.on("response", self._on_response)
|
|
95
|
+
self.page.on("requestfailed", self._on_request_failed)
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
99
|
+
self.page.remove_listener("response", self._on_response)
|
|
100
|
+
self.page.remove_listener("requestfailed", self._on_request_failed)
|
|
101
|
+
|
|
102
|
+
payload = {
|
|
103
|
+
"task": self.task,
|
|
104
|
+
"seconds": round(time.time() - self._started, 3),
|
|
105
|
+
"requests": self.requests,
|
|
106
|
+
"raised": None if exc_type is None else f"{exc_type.__name__}: {exc}",
|
|
107
|
+
}
|
|
108
|
+
if self.watch_dir and _snapshot:
|
|
109
|
+
after = _snapshot(self.watch_dir)
|
|
110
|
+
payload["changes"] = _diff(self._before_snapshot, after)
|
|
111
|
+
|
|
112
|
+
self.result = payload
|
|
113
|
+
return False # never suppress the exception -- a receipt records a failure, it doesn't hide one
|
|
114
|
+
|
|
115
|
+
def to_dict(self) -> dict:
|
|
116
|
+
if self.result is None:
|
|
117
|
+
raise RuntimeError("capture() hasn't exited its `with` block yet")
|
|
118
|
+
return to_providence_bundle(self.result)
|
|
119
|
+
|
|
120
|
+
def write(self, path: str) -> None:
|
|
121
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
122
|
+
json.dump(self.to_dict(), f, indent=2)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def capture(page, task: str, watch_dir: str | None = None) -> Capture:
|
|
126
|
+
return Capture(page, task, watch_dir)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def to_providence_bundle(payload: dict, tool: str = "clicked") -> dict:
|
|
130
|
+
"""A single-file Providence bundle (see
|
|
131
|
+
https://github.com/MaXiMo000/providence's SPEC.md), written by hand to
|
|
132
|
+
its documented recipe -- same approach custody takes, for the same
|
|
133
|
+
reason: providence-evidence wasn't published to PyPI when this was
|
|
134
|
+
written."""
|
|
135
|
+
blob = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode()
|
|
136
|
+
return {
|
|
137
|
+
"providence_version": 1,
|
|
138
|
+
"generated_at": time.time(),
|
|
139
|
+
"tool": tool,
|
|
140
|
+
"payload": payload,
|
|
141
|
+
"sha256": hashlib.sha256(blob).hexdigest(),
|
|
142
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clicked-evidence
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Prove what one browser interaction actually did -- every network request it made, and optionally what changed on a local backing store.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/MaXiMo000/clicked
|
|
7
|
+
Project-URL: Source, https://github.com/MaXiMo000/clicked
|
|
8
|
+
Project-URL: Issues, https://github.com/MaXiMo000/clicked/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/MaXiMo000/clicked/releases
|
|
10
|
+
Keywords: browser,playwright,testing,evidence,provenance,e2e
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Testing
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: receipt-evidence>=0.1.1
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# clicked
|
|
23
|
+
|
|
24
|
+
[](https://github.com/MaXiMo000/clicked/actions/workflows/ci.yml)
|
|
25
|
+
|
|
26
|
+
**Prove what one browser interaction actually did -- every network
|
|
27
|
+
request it made, and optionally what changed on a local backing store.**
|
|
28
|
+
|
|
29
|
+
[`receipt`](https://github.com/MaXiMo000/receipt) proves what a shell
|
|
30
|
+
command touched. [`custody`](https://github.com/MaXiMo000/custody) proves
|
|
31
|
+
what an AI agent's tool call touched. `clicked` proves what a *click*
|
|
32
|
+
touched -- the same "verified execution, not just self-report" idea, one
|
|
33
|
+
layer up: a browser-automation test that asserts "clicking Save sends the
|
|
34
|
+
right request" is trusting the test's own assumption unless something
|
|
35
|
+
independently recorded what the browser actually sent.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from clicked import capture
|
|
39
|
+
|
|
40
|
+
with capture(page, task="click save button") as c:
|
|
41
|
+
page.click("#save")
|
|
42
|
+
page.wait_for_timeout(200)
|
|
43
|
+
|
|
44
|
+
print(c.to_dict())
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"providence_version": 1,
|
|
50
|
+
"tool": "clicked",
|
|
51
|
+
"payload": {
|
|
52
|
+
"task": "click save button",
|
|
53
|
+
"seconds": 0.324,
|
|
54
|
+
"requests": [
|
|
55
|
+
{"url": "http://127.0.0.1:60672/api/save", "method": "POST",
|
|
56
|
+
"post_data": "{\"name\":\"ada\"}", "status": 200, "failure": null}
|
|
57
|
+
],
|
|
58
|
+
"raised": null
|
|
59
|
+
},
|
|
60
|
+
"sha256": "ed727efcdccbc..."
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
(Real output, from `tests/test_capture_live.py` -- a real headless
|
|
65
|
+
Chromium, clicking a real button, against a real local HTTP server.)
|
|
66
|
+
|
|
67
|
+
## Install
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
pip install clicked-evidence
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
For once, no PyPI name-squatting to work around -- both `clicked` and
|
|
74
|
+
`clicked-evidence` were actually free.
|
|
75
|
+
|
|
76
|
+
## Use
|
|
77
|
+
|
|
78
|
+
`capture()` takes any Playwright `Page` object and a `task` description.
|
|
79
|
+
Everything inside the `with` block is one interaction:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from clicked import capture
|
|
83
|
+
|
|
84
|
+
with capture(page, task="delete account button") as c:
|
|
85
|
+
page.click("#delete-account-button")
|
|
86
|
+
page.wait_for_load_state("networkidle")
|
|
87
|
+
|
|
88
|
+
c.write("receipt.json") # a Providence bundle -- see below
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`c.requests` is every network request/response pair that happened during
|
|
92
|
+
the block (URL, method, POST body, status, or a `failure` reason if the
|
|
93
|
+
request never got a response at all). This is what was actually sent and
|
|
94
|
+
actually came back -- not what the page's own JavaScript claims it sent.
|
|
95
|
+
|
|
96
|
+
## Redaction
|
|
97
|
+
|
|
98
|
+
`url` and `post_data` are swept through
|
|
99
|
+
[`receipt.redact`](https://github.com/MaXiMo000/receipt) before being
|
|
100
|
+
stored -- a real interaction routinely carries a credential in a query
|
|
101
|
+
string (`?api_key=...`) or a form/JSON POST body (`{"password": "..."}`),
|
|
102
|
+
and a receipt is meant to be kept and handed to someone else as evidence,
|
|
103
|
+
not a second place that credential now lives. Same regex-based, best-
|
|
104
|
+
effort sweep `receipt` and `custody` already use, not exhaustive -- see
|
|
105
|
+
receipt's own README for what it doesn't catch.
|
|
106
|
+
|
|
107
|
+
### Watching the filesystem too
|
|
108
|
+
|
|
109
|
+
Pass `watch_dir` to also snapshot-diff a local directory before and after
|
|
110
|
+
the interaction, reusing `receipt.snapshot`'s real `snapshot()`/`diff()`
|
|
111
|
+
core directly (not reimplemented):
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
with capture(page, task="click save button", watch_dir="./data") as c:
|
|
115
|
+
page.click("#save")
|
|
116
|
+
page.wait_for_timeout(200)
|
|
117
|
+
|
|
118
|
+
print(c.result["changes"]) # {"added": ["saved.json"], "modified": [], ...}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
This closes the loop from *client* interaction to *backend* side effect --
|
|
122
|
+
proving the click didn't just send a request that returned 200, but that
|
|
123
|
+
a real file actually landed on disk because of it. `watch_dir=None` (the
|
|
124
|
+
default) skips this step entirely; no extra install needed either way.
|
|
125
|
+
|
|
126
|
+
### Why no CLI
|
|
127
|
+
|
|
128
|
+
Every other tool in this portfolio is a CLI because each one wraps a
|
|
129
|
+
command, a file, or a config a human or CI job invokes directly. `clicked`
|
|
130
|
+
instruments one interaction inside a caller's *own* Playwright script --
|
|
131
|
+
there's nothing to invoke from a shell that isn't already that script.
|
|
132
|
+
The receipt itself is a Providence bundle either way (`c.to_dict()` /
|
|
133
|
+
`c.write(path)`), so it plugs into the same evidence pipeline as
|
|
134
|
+
everything else in this portfolio without needing its own entry point.
|
|
135
|
+
|
|
136
|
+
## Playwright is never imported here
|
|
137
|
+
|
|
138
|
+
`capture()` only calls `.on()` and `.remove_listener()` on whatever object
|
|
139
|
+
you pass it -- duck-typed, not a hard dependency on Playwright or any
|
|
140
|
+
particular version of it. `receipt-evidence` is the one real dependency
|
|
141
|
+
this package has -- a hard one, not an extra, because redaction (above)
|
|
142
|
+
is part of the core network-capture path, not an opt-in feature; it's
|
|
143
|
+
also itself dependency-free, so this stays a light install either way.
|
|
144
|
+
|
|
145
|
+
## Tests
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
pip install -e "."
|
|
149
|
+
python tests/test_capture.py # pure logic, a fake page object, no browser needed
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
pip install -e "." playwright
|
|
154
|
+
python -m playwright install chromium
|
|
155
|
+
python tests/test_capture_live.py # real Chromium, real HTTP server, real network + filesystem effects
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
17 tests total (13 + 4). The live suite is the one that actually matters
|
|
159
|
+
for a package whose whole point is "did this really happen" -- it skips
|
|
160
|
+
cleanly, rather than failing, if Playwright or its browser binary isn't
|
|
161
|
+
installed.
|
|
162
|
+
|
|
163
|
+
MIT licensed.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
clicked/__init__.py
|
|
5
|
+
clicked/capture.py
|
|
6
|
+
clicked_evidence.egg-info/PKG-INFO
|
|
7
|
+
clicked_evidence.egg-info/SOURCES.txt
|
|
8
|
+
clicked_evidence.egg-info/dependency_links.txt
|
|
9
|
+
clicked_evidence.egg-info/requires.txt
|
|
10
|
+
clicked_evidence.egg-info/top_level.txt
|
|
11
|
+
tests/test_capture.py
|
|
12
|
+
tests/test_capture_live.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
receipt-evidence>=0.1.1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
clicked
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
# "clicked" and "clicked-evidence" were both actually available on PyPI --
|
|
3
|
+
# no squatting to work around here, for once. The installed package stays
|
|
4
|
+
# `clicked` either way (an installed *command* isn't relevant: this is a
|
|
5
|
+
# library, imported from a caller's own browser-automation script, not a
|
|
6
|
+
# CLI tool -- see README's "Why no CLI").
|
|
7
|
+
name = "clicked-evidence"
|
|
8
|
+
version = "0.1.0"
|
|
9
|
+
description = "Prove what one browser interaction actually did -- every network request it made, and optionally what changed on a local backing store."
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
keywords = ["browser", "playwright", "testing", "evidence", "provenance", "e2e"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Software Development :: Testing",
|
|
21
|
+
]
|
|
22
|
+
# Playwright is duck-typed, never imported here, so whichever version (or,
|
|
23
|
+
# in principle, another library with the same .on()/.remove_listener()
|
|
24
|
+
# shape) the caller already depends on is used as-is. receipt-evidence is
|
|
25
|
+
# the one real dependency this package has -- it's a hard one, not an
|
|
26
|
+
# extra, because capture()'s core network recording redacts every URL and
|
|
27
|
+
# POST body through receipt.redact before storing it (a credential in a
|
|
28
|
+
# query string or form submit is a real, common shape, not an edge case),
|
|
29
|
+
# and that safety shouldn't be something a caller has to opt into by
|
|
30
|
+
# installing an extra. receipt-evidence is itself dependency-free, so this
|
|
31
|
+
# stays a light install. snapshot()/diff() ride along on the same
|
|
32
|
+
# dependency for the optional watch_dir feature.
|
|
33
|
+
dependencies = ["receipt-evidence>=0.1.1"] # 0.1.1: redact.py's quoted-key fix, needed for JSON POST bodies
|
|
34
|
+
|
|
35
|
+
urls.Homepage = "https://github.com/MaXiMo000/clicked"
|
|
36
|
+
urls.Source = "https://github.com/MaXiMo000/clicked"
|
|
37
|
+
urls.Issues = "https://github.com/MaXiMo000/clicked/issues"
|
|
38
|
+
urls.Changelog = "https://github.com/MaXiMo000/clicked/releases"
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["setuptools>=77"]
|
|
42
|
+
build-backend = "setuptools.build_meta"
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.packages.find]
|
|
45
|
+
include = ["clicked*"]
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Run: python tests/test_capture.py
|
|
2
|
+
|
|
3
|
+
Pure logic, against a fake page object exposing just the two methods
|
|
4
|
+
Capture actually calls (`.on()`, `.remove_listener()`) -- no real browser
|
|
5
|
+
needed here. test_capture_live.py is the real-Playwright end-to-end
|
|
6
|
+
counterpart.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import pathlib
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import unittest
|
|
14
|
+
|
|
15
|
+
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
|
16
|
+
|
|
17
|
+
from clicked.capture import capture
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FakePage:
|
|
21
|
+
"""Mimics just enough of Playwright's Page event API to drive Capture
|
|
22
|
+
without a real browser: .on()/.remove_listener() register/unregister
|
|
23
|
+
handlers by event name, and _fire() simulates the browser calling them."""
|
|
24
|
+
|
|
25
|
+
def __init__(self):
|
|
26
|
+
self._handlers: dict[str, list] = {}
|
|
27
|
+
|
|
28
|
+
def on(self, event, handler):
|
|
29
|
+
self._handlers.setdefault(event, []).append(handler)
|
|
30
|
+
|
|
31
|
+
def remove_listener(self, event, handler):
|
|
32
|
+
self._handlers[event].remove(handler)
|
|
33
|
+
|
|
34
|
+
def _fire(self, event, arg):
|
|
35
|
+
for h in list(self._handlers.get(event, [])):
|
|
36
|
+
h(arg)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FakeRequest:
|
|
40
|
+
def __init__(self, url, method="GET", post_data=None, resource_type="fetch", failure=None):
|
|
41
|
+
self.url, self.method, self.post_data = url, method, post_data
|
|
42
|
+
self.resource_type, self.failure = resource_type, failure
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FakeResponse:
|
|
46
|
+
def __init__(self, request, status=200):
|
|
47
|
+
self.request, self.status = request, status
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TestCapture(unittest.TestCase):
|
|
51
|
+
def test_a_single_response_is_recorded(self):
|
|
52
|
+
page = FakePage()
|
|
53
|
+
with capture(page, task="click submit") as c:
|
|
54
|
+
page._fire("response", FakeResponse(FakeRequest("https://api.example.com/orders", "POST")))
|
|
55
|
+
self.assertEqual(len(c.requests), 1)
|
|
56
|
+
self.assertEqual(c.requests[0]["url"], "https://api.example.com/orders")
|
|
57
|
+
self.assertEqual(c.requests[0]["method"], "POST")
|
|
58
|
+
self.assertEqual(c.requests[0]["status"], 200)
|
|
59
|
+
|
|
60
|
+
def test_multiple_responses_during_one_interaction_are_all_recorded(self):
|
|
61
|
+
page = FakePage()
|
|
62
|
+
with capture(page, task="load dashboard") as c:
|
|
63
|
+
page._fire("response", FakeResponse(FakeRequest("https://a.example.com/1")))
|
|
64
|
+
page._fire("response", FakeResponse(FakeRequest("https://a.example.com/2")))
|
|
65
|
+
self.assertEqual(len(c.requests), 2)
|
|
66
|
+
|
|
67
|
+
def test_a_credential_in_the_url_is_redacted(self):
|
|
68
|
+
# Real leak shape: an API key passed as a query param, or a DSN-style
|
|
69
|
+
# credentialed URL -- the exact bug class receipt.redact exists for.
|
|
70
|
+
page = FakePage()
|
|
71
|
+
with capture(page, task="load report") as c:
|
|
72
|
+
page._fire("response", FakeResponse(FakeRequest(
|
|
73
|
+
"https://api.example.com/report?api_key=sk-supersecret12345")))
|
|
74
|
+
self.assertNotIn("sk-supersecret12345", c.requests[0]["url"])
|
|
75
|
+
self.assertIn("[REDACTED]", c.requests[0]["url"])
|
|
76
|
+
|
|
77
|
+
def test_a_credential_in_post_data_is_redacted(self):
|
|
78
|
+
page = FakePage()
|
|
79
|
+
with capture(page, task="log in") as c:
|
|
80
|
+
page._fire("response", FakeResponse(FakeRequest(
|
|
81
|
+
"https://api.example.com/login", "POST",
|
|
82
|
+
post_data='{"password": "hunter2trombone"}')))
|
|
83
|
+
self.assertNotIn("hunter2trombone", c.requests[0]["post_data"])
|
|
84
|
+
|
|
85
|
+
def test_a_failed_requests_url_and_post_data_are_also_redacted(self):
|
|
86
|
+
# _on_request_failed is a separate code path from _on_response --
|
|
87
|
+
# a fix to one alone would leave the other leaking.
|
|
88
|
+
page = FakePage()
|
|
89
|
+
with capture(page, task="submit") as c:
|
|
90
|
+
page._fire("requestfailed", FakeRequest(
|
|
91
|
+
"https://api.example.com/x?token=ghp_abcdefghijklmnopqrst1234",
|
|
92
|
+
post_data="PASSWORD=hunter2trombone",
|
|
93
|
+
failure="net::ERR_FAILED"))
|
|
94
|
+
self.assertNotIn("ghp_abcdefghijklmnopqrst1234", c.requests[0]["url"])
|
|
95
|
+
self.assertNotIn("hunter2trombone", c.requests[0]["post_data"])
|
|
96
|
+
|
|
97
|
+
def test_a_url_with_no_credential_is_left_unredacted(self):
|
|
98
|
+
page = FakePage()
|
|
99
|
+
with capture(page, task="browse") as c:
|
|
100
|
+
page._fire("response", FakeResponse(FakeRequest("https://example.com/orders/42")))
|
|
101
|
+
self.assertEqual(c.requests[0]["url"], "https://example.com/orders/42")
|
|
102
|
+
|
|
103
|
+
def test_a_failed_request_is_recorded_with_no_status(self):
|
|
104
|
+
page = FakePage()
|
|
105
|
+
with capture(page, task="submit form") as c:
|
|
106
|
+
page._fire("requestfailed", FakeRequest("https://gone.example.com/x", failure="net::ERR_NAME_NOT_RESOLVED"))
|
|
107
|
+
self.assertEqual(c.requests[0]["status"], None)
|
|
108
|
+
self.assertEqual(c.requests[0]["failure"], "net::ERR_NAME_NOT_RESOLVED")
|
|
109
|
+
|
|
110
|
+
def test_listeners_are_removed_after_the_with_block(self):
|
|
111
|
+
"""A response firing after the interaction is over must not sneak
|
|
112
|
+
into a receipt that already claims to be closed."""
|
|
113
|
+
page = FakePage()
|
|
114
|
+
with capture(page, task="x") as c:
|
|
115
|
+
page._fire("response", FakeResponse(FakeRequest("https://a.example.com/during")))
|
|
116
|
+
page._fire("response", FakeResponse(FakeRequest("https://a.example.com/after")))
|
|
117
|
+
self.assertEqual(len(c.requests), 1)
|
|
118
|
+
self.assertEqual(c.requests[0]["url"], "https://a.example.com/during")
|
|
119
|
+
|
|
120
|
+
def test_an_exception_inside_the_with_block_is_recorded_not_swallowed(self):
|
|
121
|
+
page = FakePage()
|
|
122
|
+
c = capture(page, task="risky click")
|
|
123
|
+
with self.assertRaises(ValueError):
|
|
124
|
+
with c:
|
|
125
|
+
raise ValueError("the click handler itself threw")
|
|
126
|
+
self.assertIn("the click handler itself threw", c.result["raised"])
|
|
127
|
+
|
|
128
|
+
def test_filesystem_changes_before_an_exception_are_still_captured(self):
|
|
129
|
+
"""A receipt records what happened, including a failure -- it
|
|
130
|
+
doesn't lose the evidence of what changed before the failure."""
|
|
131
|
+
with tempfile.TemporaryDirectory() as d:
|
|
132
|
+
watch_dir = pathlib.Path(d)
|
|
133
|
+
page = FakePage()
|
|
134
|
+
c = capture(page, task="x", watch_dir=str(watch_dir))
|
|
135
|
+
with self.assertRaises(RuntimeError):
|
|
136
|
+
with c:
|
|
137
|
+
(watch_dir / "new.txt").write_text("hi")
|
|
138
|
+
raise RuntimeError("boom")
|
|
139
|
+
self.assertIn("RuntimeError: boom", c.result["raised"])
|
|
140
|
+
self.assertIn("new.txt", c.result["changes"]["added"])
|
|
141
|
+
|
|
142
|
+
def test_to_dict_before_the_with_block_exits_is_an_error(self):
|
|
143
|
+
page = FakePage()
|
|
144
|
+
c = capture(page, task="x")
|
|
145
|
+
with self.assertRaises(RuntimeError):
|
|
146
|
+
c.to_dict()
|
|
147
|
+
|
|
148
|
+
def test_to_dict_is_a_conformant_providence_bundle(self):
|
|
149
|
+
page = FakePage()
|
|
150
|
+
with capture(page, task="x") as c:
|
|
151
|
+
pass
|
|
152
|
+
bundle = c.to_dict()
|
|
153
|
+
self.assertEqual(bundle["providence_version"], 1)
|
|
154
|
+
self.assertEqual(bundle["tool"], "clicked")
|
|
155
|
+
self.assertEqual(bundle["payload"]["task"], "x")
|
|
156
|
+
self.assertIn("sha256", bundle)
|
|
157
|
+
|
|
158
|
+
def test_write_produces_a_real_file_providence_check_can_validate(self):
|
|
159
|
+
page = FakePage()
|
|
160
|
+
with capture(page, task="x") as c:
|
|
161
|
+
page._fire("response", FakeResponse(FakeRequest("https://a.example.com/1")))
|
|
162
|
+
|
|
163
|
+
with tempfile.TemporaryDirectory() as d:
|
|
164
|
+
out = str(pathlib.Path(d) / "receipt.json")
|
|
165
|
+
c.write(out)
|
|
166
|
+
import json
|
|
167
|
+
doc = json.loads(pathlib.Path(out).read_text())
|
|
168
|
+
self.assertEqual(doc["providence_version"], 1)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
unittest.main()
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Run: python tests/test_capture_live.py
|
|
2
|
+
|
|
3
|
+
Real Playwright, a real headless Chromium, a real local HTTP server --
|
|
4
|
+
not mocked. This is the actual point of the package: proving it captures
|
|
5
|
+
what a real browser really did, not just that fake event objects fan out
|
|
6
|
+
to the right handlers (test_capture.py already covers that).
|
|
7
|
+
|
|
8
|
+
Requires `pip install playwright && playwright install chromium` --
|
|
9
|
+
skipped cleanly (not failed) if either isn't available, the same "can't
|
|
10
|
+
verify, don't guess" discipline as every unverified path elsewhere in
|
|
11
|
+
this portfolio.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import http.server
|
|
16
|
+
import json
|
|
17
|
+
import pathlib
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import threading
|
|
21
|
+
import unittest
|
|
22
|
+
|
|
23
|
+
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
from playwright.sync_api import sync_playwright
|
|
27
|
+
_PLAYWRIGHT_AVAILABLE = True
|
|
28
|
+
except ImportError:
|
|
29
|
+
_PLAYWRIGHT_AVAILABLE = False
|
|
30
|
+
|
|
31
|
+
from clicked.capture import capture
|
|
32
|
+
|
|
33
|
+
_PAGE_HTML = b"""<!doctype html><html><body>
|
|
34
|
+
<button id="save" onclick="save()">Save</button>
|
|
35
|
+
<button id="login" onclick="login()">Login</button>
|
|
36
|
+
<script>
|
|
37
|
+
async function save() {
|
|
38
|
+
await fetch('/api/save', {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: {'Content-Type': 'application/json'},
|
|
41
|
+
body: JSON.stringify({name: 'ada'}),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
async function login() {
|
|
45
|
+
await fetch('/api/login?api_key=sk-realtestsecret12345', {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: {'Content-Type': 'application/json'},
|
|
48
|
+
body: JSON.stringify({user: 'ada', password: 'hunter2trombone'}),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
</script>
|
|
52
|
+
</body></html>"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _make_handler(write_dir: pathlib.Path):
|
|
56
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
57
|
+
def log_message(self, *a):
|
|
58
|
+
pass # keep test output clean
|
|
59
|
+
|
|
60
|
+
def do_GET(self):
|
|
61
|
+
self.send_response(200)
|
|
62
|
+
self.send_header("Content-Type", "text/html")
|
|
63
|
+
self.end_headers()
|
|
64
|
+
self.wfile.write(_PAGE_HTML)
|
|
65
|
+
|
|
66
|
+
def do_POST(self):
|
|
67
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
68
|
+
body = self.rfile.read(length)
|
|
69
|
+
if self.path == "/api/save":
|
|
70
|
+
# The real side effect being proven here: a click leads to
|
|
71
|
+
# a network request leads to a file actually being written.
|
|
72
|
+
(write_dir / "saved.json").write_bytes(body)
|
|
73
|
+
self.send_response(200)
|
|
74
|
+
self.send_header("Content-Type", "application/json")
|
|
75
|
+
self.end_headers()
|
|
76
|
+
self.wfile.write(b'{"ok": true}')
|
|
77
|
+
elif self.path.startswith("/api/login"):
|
|
78
|
+
self.send_response(200)
|
|
79
|
+
self.send_header("Content-Type", "application/json")
|
|
80
|
+
self.end_headers()
|
|
81
|
+
self.wfile.write(b'{"ok": true}')
|
|
82
|
+
else:
|
|
83
|
+
self.send_response(404)
|
|
84
|
+
self.end_headers()
|
|
85
|
+
|
|
86
|
+
return Handler
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@unittest.skipUnless(_PLAYWRIGHT_AVAILABLE, "playwright not installed -- skipped, not failed")
|
|
90
|
+
class TestCaptureLive(unittest.TestCase):
|
|
91
|
+
def setUp(self):
|
|
92
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
93
|
+
self.watch_dir = pathlib.Path(self.tmp.name)
|
|
94
|
+
self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _make_handler(self.watch_dir))
|
|
95
|
+
self.port = self.server.server_address[1]
|
|
96
|
+
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
|
97
|
+
self.thread.start()
|
|
98
|
+
|
|
99
|
+
self.playwright = sync_playwright().start()
|
|
100
|
+
self.browser = self.playwright.chromium.launch()
|
|
101
|
+
self.page = self.browser.new_page()
|
|
102
|
+
self.page.goto(f"http://127.0.0.1:{self.port}/")
|
|
103
|
+
|
|
104
|
+
def tearDown(self):
|
|
105
|
+
self.browser.close()
|
|
106
|
+
self.playwright.stop()
|
|
107
|
+
self.server.shutdown()
|
|
108
|
+
self.thread.join()
|
|
109
|
+
self.tmp.cleanup()
|
|
110
|
+
|
|
111
|
+
def test_a_real_click_that_fires_a_real_fetch_is_captured(self):
|
|
112
|
+
with capture(self.page, task="click save button") as c:
|
|
113
|
+
self.page.click("#save")
|
|
114
|
+
self.page.wait_for_timeout(200) # let the fetch actually complete
|
|
115
|
+
|
|
116
|
+
api_calls = [r for r in c.requests if r["url"].endswith("/api/save")]
|
|
117
|
+
self.assertEqual(len(api_calls), 1)
|
|
118
|
+
self.assertEqual(api_calls[0]["method"], "POST")
|
|
119
|
+
self.assertEqual(api_calls[0]["status"], 200)
|
|
120
|
+
# The real POST body the browser actually sent -- not asserted
|
|
121
|
+
# from the page's own JS, read back from what the browser did.
|
|
122
|
+
self.assertEqual(json.loads(api_calls[0]["post_data"]), {"name": "ada"})
|
|
123
|
+
|
|
124
|
+
def test_the_real_filesystem_side_effect_of_the_click_is_diffed(self):
|
|
125
|
+
"""The click causes the *server* to write a real file -- proving
|
|
126
|
+
this isn't just DOM/network observation, it closes the loop to an
|
|
127
|
+
actual backend side effect, the same "verified execution" idea
|
|
128
|
+
receipt/custody already prove for a shell command / tool call."""
|
|
129
|
+
self.assertFalse((self.watch_dir / "saved.json").exists())
|
|
130
|
+
|
|
131
|
+
with capture(self.page, task="click save button", watch_dir=str(self.watch_dir)) as c:
|
|
132
|
+
self.page.click("#save")
|
|
133
|
+
self.page.wait_for_timeout(200)
|
|
134
|
+
|
|
135
|
+
self.assertTrue((self.watch_dir / "saved.json").exists())
|
|
136
|
+
self.assertIn("saved.json", c.result["changes"]["added"])
|
|
137
|
+
|
|
138
|
+
def test_a_real_credential_a_real_browser_actually_sent_is_redacted(self):
|
|
139
|
+
"""A real Chromium, a real fetch, with a real (test) API key in the
|
|
140
|
+
URL and a real password in the JSON POST body -- the exact leak
|
|
141
|
+
shape the audit found capture() had zero redaction for. Checks
|
|
142
|
+
the captured output, not the page's own JS, since the whole
|
|
143
|
+
point of this package is not trusting self-report."""
|
|
144
|
+
with capture(self.page, task="login") as c:
|
|
145
|
+
self.page.click("#login")
|
|
146
|
+
self.page.wait_for_timeout(200)
|
|
147
|
+
|
|
148
|
+
login_calls = [r for r in c.requests if "/api/login" in r["url"]]
|
|
149
|
+
self.assertEqual(len(login_calls), 1)
|
|
150
|
+
raw = json.dumps(c.to_dict())
|
|
151
|
+
self.assertNotIn("sk-realtestsecret12345", raw)
|
|
152
|
+
self.assertNotIn("hunter2trombone", raw)
|
|
153
|
+
self.assertIn("[REDACTED]", login_calls[0]["url"])
|
|
154
|
+
self.assertIn("[REDACTED]", login_calls[0]["post_data"])
|
|
155
|
+
|
|
156
|
+
def test_no_interaction_means_no_requests_captured(self):
|
|
157
|
+
with capture(self.page, task="do nothing") as c:
|
|
158
|
+
self.page.wait_for_timeout(50)
|
|
159
|
+
api_calls = [r for r in c.requests if r["url"].endswith("/api/save")]
|
|
160
|
+
self.assertEqual(api_calls, [])
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
unittest.main()
|