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.
@@ -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
+ [![ci](https://github.com/MaXiMo000/clicked/actions/workflows/ci.yml/badge.svg)](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
+ [![ci](https://github.com/MaXiMo000/clicked/actions/workflows/ci.yml/badge.svg)](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,4 @@
1
+ from .capture import Capture, capture
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["capture", "Capture"]
@@ -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
+ [![ci](https://github.com/MaXiMo000/clicked/actions/workflows/ci.yml/badge.svg)](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
+ receipt-evidence>=0.1.1
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()