django-snapnote 0.1.1__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.
- django_snapnote-0.1.1/.gitignore +55 -0
- django_snapnote-0.1.1/CHANGELOG.md +10 -0
- django_snapnote-0.1.1/LICENSE +21 -0
- django_snapnote-0.1.1/PKG-INFO +112 -0
- django_snapnote-0.1.1/README.md +93 -0
- django_snapnote-0.1.1/frontend/e2e/smoke.mjs +107 -0
- django_snapnote-0.1.1/frontend/package-lock.json +2964 -0
- django_snapnote-0.1.1/frontend/package.json +26 -0
- django_snapnote-0.1.1/frontend/src/AnnotationEditor.tsx +292 -0
- django_snapnote-0.1.1/frontend/src/App.tsx +225 -0
- django_snapnote-0.1.1/frontend/src/api.ts +35 -0
- django_snapnote-0.1.1/frontend/src/capture.ts +42 -0
- django_snapnote-0.1.1/frontend/src/drawing.test.ts +32 -0
- django_snapnote-0.1.1/frontend/src/drawing.ts +108 -0
- django_snapnote-0.1.1/frontend/src/icons.tsx +44 -0
- django_snapnote-0.1.1/frontend/src/main.tsx +31 -0
- django_snapnote-0.1.1/frontend/src/styles.css +70 -0
- django_snapnote-0.1.1/frontend/src/types.ts +39 -0
- django_snapnote-0.1.1/frontend/src/vite-env.d.ts +1 -0
- django_snapnote-0.1.1/frontend/tsconfig.json +14 -0
- django_snapnote-0.1.1/frontend/vite.config.ts +22 -0
- django_snapnote-0.1.1/pyproject.toml +44 -0
- django_snapnote-0.1.1/src/snapnote/__init__.py +1 -0
- django_snapnote-0.1.1/src/snapnote/admin.py +12 -0
- django_snapnote-0.1.1/src/snapnote/apps.py +14 -0
- django_snapnote-0.1.1/src/snapnote/checks.py +22 -0
- django_snapnote-0.1.1/src/snapnote/conf.py +25 -0
- django_snapnote-0.1.1/src/snapnote/forms.py +83 -0
- django_snapnote-0.1.1/src/snapnote/locale/es/LC_MESSAGES/django.mo +0 -0
- django_snapnote-0.1.1/src/snapnote/locale/es/LC_MESSAGES/django.po +186 -0
- django_snapnote-0.1.1/src/snapnote/management/__init__.py +0 -0
- django_snapnote-0.1.1/src/snapnote/management/commands/__init__.py +0 -0
- django_snapnote-0.1.1/src/snapnote/management/commands/snapnote_purge.py +26 -0
- django_snapnote-0.1.1/src/snapnote/middleware.py +32 -0
- django_snapnote-0.1.1/src/snapnote/migrations/0001_initial.py +92 -0
- django_snapnote-0.1.1/src/snapnote/migrations/__init__.py +0 -0
- django_snapnote-0.1.1/src/snapnote/models.py +120 -0
- django_snapnote-0.1.1/src/snapnote/notifiers.py +42 -0
- django_snapnote-0.1.1/src/snapnote/permissions.py +2 -0
- django_snapnote-0.1.1/src/snapnote/signals.py +5 -0
- django_snapnote-0.1.1/src/snapnote/static/snapnote/management.css +26 -0
- django_snapnote-0.1.1/src/snapnote/static/snapnote/snapnote.js +40 -0
- django_snapnote-0.1.1/src/snapnote/templates/snapnote/base.html +12 -0
- django_snapnote-0.1.1/src/snapnote/templates/snapnote/button.html +1 -0
- django_snapnote-0.1.1/src/snapnote/templates/snapnote/includes/status_badge.html +1 -0
- django_snapnote-0.1.1/src/snapnote/templates/snapnote/ticket_confirm_delete.html +12 -0
- django_snapnote-0.1.1/src/snapnote/templates/snapnote/ticket_detail.html +42 -0
- django_snapnote-0.1.1/src/snapnote/templates/snapnote/ticket_list.html +39 -0
- django_snapnote-0.1.1/src/snapnote/templatetags/__init__.py +0 -0
- django_snapnote-0.1.1/src/snapnote/templatetags/snapnote_tags.py +65 -0
- django_snapnote-0.1.1/src/snapnote/urls.py +14 -0
- django_snapnote-0.1.1/src/snapnote/views.py +123 -0
- django_snapnote-0.1.1/tests/__init__.py +0 -0
- django_snapnote-0.1.1/tests/conftest.py +76 -0
- django_snapnote-0.1.1/tests/helpers.py +13 -0
- django_snapnote-0.1.1/tests/test_button.py +49 -0
- django_snapnote-0.1.1/tests/test_management.py +102 -0
- django_snapnote-0.1.1/tests/test_report.py +139 -0
- django_snapnote-0.1.1/tests/testproject/__init__.py +0 -0
- django_snapnote-0.1.1/tests/testproject/hostapp/__init__.py +0 -0
- django_snapnote-0.1.1/tests/testproject/hostapp/apps.py +6 -0
- django_snapnote-0.1.1/tests/testproject/hostapp/models.py +22 -0
- django_snapnote-0.1.1/tests/testproject/settings.py +69 -0
- django_snapnote-0.1.1/tests/testproject/templates/alt_base.html +1 -0
- django_snapnote-0.1.1/tests/testproject/templates/host_base.html +1 -0
- django_snapnote-0.1.1/tests/testproject/urls.py +37 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# These are some examples of commonly ignored file patterns.
|
|
2
|
+
# You should customize this list as applicable to your project.
|
|
3
|
+
# Learn more about .gitignore:
|
|
4
|
+
# https://www.atlassian.com/git/tutorials/saving-changes/gitignore
|
|
5
|
+
|
|
6
|
+
# Node artifact files
|
|
7
|
+
node_modules/
|
|
8
|
+
dist/
|
|
9
|
+
|
|
10
|
+
# Compiled Java class files
|
|
11
|
+
*.class
|
|
12
|
+
|
|
13
|
+
# Compiled Python bytecode
|
|
14
|
+
*.py[cod]
|
|
15
|
+
|
|
16
|
+
# Log files
|
|
17
|
+
*.log
|
|
18
|
+
|
|
19
|
+
# Package files
|
|
20
|
+
*.jar
|
|
21
|
+
|
|
22
|
+
# Maven
|
|
23
|
+
target/
|
|
24
|
+
dist/
|
|
25
|
+
|
|
26
|
+
# JetBrains IDE
|
|
27
|
+
.idea/
|
|
28
|
+
|
|
29
|
+
# Unit test reports
|
|
30
|
+
TEST*.xml
|
|
31
|
+
|
|
32
|
+
# Generated by MacOS
|
|
33
|
+
.DS_Store
|
|
34
|
+
|
|
35
|
+
# Generated by Windows
|
|
36
|
+
Thumbs.db
|
|
37
|
+
|
|
38
|
+
# Applications
|
|
39
|
+
*.app
|
|
40
|
+
*.exe
|
|
41
|
+
*.war
|
|
42
|
+
|
|
43
|
+
# Large media files
|
|
44
|
+
*.mp4
|
|
45
|
+
*.tiff
|
|
46
|
+
*.avi
|
|
47
|
+
*.flv
|
|
48
|
+
*.mov
|
|
49
|
+
*.wmv
|
|
50
|
+
|
|
51
|
+
.venv/
|
|
52
|
+
.ruff_cache/
|
|
53
|
+
.pytest_cache/
|
|
54
|
+
*.egg-info/
|
|
55
|
+
build/
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
- First release on PyPI (0.1.0 was a TestPyPI dry run).
|
|
6
|
+
|
|
7
|
+
## 0.1.0
|
|
8
|
+
|
|
9
|
+
- First release: floating report button with tab capture (or image upload fallback), annotation
|
|
10
|
+
editor, ticket model, report endpoint, management views, admin, notifiers, purge command.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Magnet SpA
|
|
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,112 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: django-snapnote
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Report problems from any page of a Django site: screenshot, annotations and a ticket list.
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Classifier: Environment :: Web Environment
|
|
8
|
+
Classifier: Framework :: Django
|
|
9
|
+
Classifier: Framework :: Django :: 4.2
|
|
10
|
+
Classifier: Framework :: Django :: 5.0
|
|
11
|
+
Classifier: Framework :: Django :: 5.1
|
|
12
|
+
Classifier: Framework :: Django :: 5.2
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: django>=4.2
|
|
17
|
+
Requires-Dist: pillow>=9.0
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# django-snapnote
|
|
21
|
+
|
|
22
|
+
Let your users report a problem from any page: a floating button captures the tab, lets them
|
|
23
|
+
draw on it and creates a ticket you can review in the admin or in built-in views.
|
|
24
|
+
|
|
25
|
+
Requires only Django (4.2–5.2) and Pillow. No DRF, no Node, no build step for you. The widget is a
|
|
26
|
+
self-contained bundle rendered in a Shadow DOM, so your CSS and ours don't interfere.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install django-snapnote
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
# settings.py
|
|
36
|
+
INSTALLED_APPS += ["snapnote"] # needs django.contrib.auth and staticfiles
|
|
37
|
+
|
|
38
|
+
# urls.py
|
|
39
|
+
path("snapnote/", include("snapnote.urls")), # report endpoint + management views
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```django
|
|
43
|
+
{# in your base template (the view needs the request context processor) #}
|
|
44
|
+
{% load snapnote_tags %}
|
|
45
|
+
{% snapnote_button %}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Then `python manage.py migrate`. Uploaded images use your `MEDIA_ROOT`/storage.
|
|
49
|
+
|
|
50
|
+
## Settings
|
|
51
|
+
|
|
52
|
+
| Setting | Default | Purpose |
|
|
53
|
+
| --- | --- | --- |
|
|
54
|
+
| `SNAPNOTE_CAN_REPORT` | `"snapnote.permissions.is_authenticated"` | Dotted path to `user -> bool`: who sees the button and may report |
|
|
55
|
+
| `SNAPNOTE_NOTIFIERS` | `["snapnote.notifiers.email"]` | Callables `(ticket, absolute_url)` run after a ticket is created |
|
|
56
|
+
| `SNAPNOTE_NOTIFY_EMAILS` | `[]` | Recipients of the email notifier (nothing is sent if empty) |
|
|
57
|
+
| `SNAPNOTE_BASE_TEMPLATE` | `None` | Template the management views extend; it only needs `{% block content %}` |
|
|
58
|
+
| `SNAPNOTE_ENABLE_MANAGEMENT_VIEWS` | `True` | List/detail views (the admin is always registered) |
|
|
59
|
+
| `SNAPNOTE_SEARCH_UNACCENT` | `False` | Accent-insensitive search (PostgreSQL + `django.contrib.postgres` only) |
|
|
60
|
+
| `SNAPNOTE_MAX_UPLOAD_MB` | `5` | Per-image size limit |
|
|
61
|
+
| `SNAPNOTE_MAX_DIMENSION` | `8000` | Max width/height in pixels |
|
|
62
|
+
| `SNAPNOTE_UPLOAD_TO` | `"snapnote/"` | Prefix in the storage |
|
|
63
|
+
| `SNAPNOTE_THROTTLE` | `"10/hour"` | Reports per user (`second`, `minute`, `hour`, `day`); uses Django's cache. `None` disables |
|
|
64
|
+
| `SNAPNOTE_AUTO_INJECT` | `False` | Also add `snapnote.middleware.AutoInjectMiddleware` to `MIDDLEWARE` to insert the button automatically. Incompatible with strict CSP and streaming responses |
|
|
65
|
+
|
|
66
|
+
Customise the look by overriding CSS variables on `#snapnote-root`
|
|
67
|
+
(`--snapnote-accent`, `--snapnote-accent-text`, `--snapnote-z-index`, `--snapnote-font`).
|
|
68
|
+
|
|
69
|
+
## Permissions
|
|
70
|
+
|
|
71
|
+
Reporting follows `SNAPNOTE_CAN_REPORT`. Managing uses the model permissions
|
|
72
|
+
`snapnote.view_ticket`, `change_ticket` and `delete_ticket` (superusers have them all).
|
|
73
|
+
Anonymous users are redirected to `LOGIN_URL`; authenticated users without permission get 403.
|
|
74
|
+
|
|
75
|
+
## Notifiers
|
|
76
|
+
|
|
77
|
+
The signal `snapnote.signals.ticket_created(sender, ticket, request_url)` is sent after commit.
|
|
78
|
+
A notifier is any callable; a failing one is logged and never breaks the report:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
# myproject/notifiers.py — Slack recipe
|
|
82
|
+
import requests
|
|
83
|
+
def slack(ticket, url):
|
|
84
|
+
requests.post(settings.SLACK_WEBHOOK, json={"text": f"New ticket: {ticket.title} {url}"}, timeout=5)
|
|
85
|
+
|
|
86
|
+
SNAPNOTE_NOTIFIERS = ["snapnote.notifiers.email", "myproject.notifiers.slack"]
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Security and deployment notes
|
|
90
|
+
|
|
91
|
+
- **Screenshots can contain sensitive data.** Serve `MEDIA_ROOT` only to authenticated staff (or use a
|
|
92
|
+
private storage); do not expose it publicly. Purge old reports with
|
|
93
|
+
`python manage.py snapnote_purge --older-than 90` (`--dry-run` to preview).
|
|
94
|
+
- **Permissions-Policy**: the browser needs `display-capture=(self)`. With `display-capture=()`
|
|
95
|
+
(also sent by some proxies) capture never works and users get the upload fallback.
|
|
96
|
+
- **CSP**: allow `img-src blob: data:` and `style-src 'unsafe-inline'` (the Shadow DOM stylesheet
|
|
97
|
+
is inline) and `script-src` for the static bundle.
|
|
98
|
+
- Uploads are validated (PNG/JPEG/WebP only, no SVG), size/dimension limited and re-encoded with Pillow.
|
|
99
|
+
- `page_url` must be http(s) and is never rendered as a clickable link otherwise.
|
|
100
|
+
- The report endpoint uses Django's CSRF protection; the template tag sets the cookie.
|
|
101
|
+
- Capture uses `getDisplayMedia` (desktop browsers). On mobile the user attaches an image.
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pip install -e . pytest pytest-django
|
|
107
|
+
pytest # SQLite; SNAPNOTE_TEST_DB=postgres for PostgreSQL
|
|
108
|
+
cd frontend && npm ci && npm run build # writes src/snapnote/static/snapnote/snapnote.js
|
|
109
|
+
npm test && npm run e2e
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The built `snapnote.js` is committed/packaged so installing the wheel never needs Node.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# django-snapnote
|
|
2
|
+
|
|
3
|
+
Let your users report a problem from any page: a floating button captures the tab, lets them
|
|
4
|
+
draw on it and creates a ticket you can review in the admin or in built-in views.
|
|
5
|
+
|
|
6
|
+
Requires only Django (4.2–5.2) and Pillow. No DRF, no Node, no build step for you. The widget is a
|
|
7
|
+
self-contained bundle rendered in a Shadow DOM, so your CSS and ours don't interfere.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install django-snapnote
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
# settings.py
|
|
17
|
+
INSTALLED_APPS += ["snapnote"] # needs django.contrib.auth and staticfiles
|
|
18
|
+
|
|
19
|
+
# urls.py
|
|
20
|
+
path("snapnote/", include("snapnote.urls")), # report endpoint + management views
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```django
|
|
24
|
+
{# in your base template (the view needs the request context processor) #}
|
|
25
|
+
{% load snapnote_tags %}
|
|
26
|
+
{% snapnote_button %}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Then `python manage.py migrate`. Uploaded images use your `MEDIA_ROOT`/storage.
|
|
30
|
+
|
|
31
|
+
## Settings
|
|
32
|
+
|
|
33
|
+
| Setting | Default | Purpose |
|
|
34
|
+
| --- | --- | --- |
|
|
35
|
+
| `SNAPNOTE_CAN_REPORT` | `"snapnote.permissions.is_authenticated"` | Dotted path to `user -> bool`: who sees the button and may report |
|
|
36
|
+
| `SNAPNOTE_NOTIFIERS` | `["snapnote.notifiers.email"]` | Callables `(ticket, absolute_url)` run after a ticket is created |
|
|
37
|
+
| `SNAPNOTE_NOTIFY_EMAILS` | `[]` | Recipients of the email notifier (nothing is sent if empty) |
|
|
38
|
+
| `SNAPNOTE_BASE_TEMPLATE` | `None` | Template the management views extend; it only needs `{% block content %}` |
|
|
39
|
+
| `SNAPNOTE_ENABLE_MANAGEMENT_VIEWS` | `True` | List/detail views (the admin is always registered) |
|
|
40
|
+
| `SNAPNOTE_SEARCH_UNACCENT` | `False` | Accent-insensitive search (PostgreSQL + `django.contrib.postgres` only) |
|
|
41
|
+
| `SNAPNOTE_MAX_UPLOAD_MB` | `5` | Per-image size limit |
|
|
42
|
+
| `SNAPNOTE_MAX_DIMENSION` | `8000` | Max width/height in pixels |
|
|
43
|
+
| `SNAPNOTE_UPLOAD_TO` | `"snapnote/"` | Prefix in the storage |
|
|
44
|
+
| `SNAPNOTE_THROTTLE` | `"10/hour"` | Reports per user (`second`, `minute`, `hour`, `day`); uses Django's cache. `None` disables |
|
|
45
|
+
| `SNAPNOTE_AUTO_INJECT` | `False` | Also add `snapnote.middleware.AutoInjectMiddleware` to `MIDDLEWARE` to insert the button automatically. Incompatible with strict CSP and streaming responses |
|
|
46
|
+
|
|
47
|
+
Customise the look by overriding CSS variables on `#snapnote-root`
|
|
48
|
+
(`--snapnote-accent`, `--snapnote-accent-text`, `--snapnote-z-index`, `--snapnote-font`).
|
|
49
|
+
|
|
50
|
+
## Permissions
|
|
51
|
+
|
|
52
|
+
Reporting follows `SNAPNOTE_CAN_REPORT`. Managing uses the model permissions
|
|
53
|
+
`snapnote.view_ticket`, `change_ticket` and `delete_ticket` (superusers have them all).
|
|
54
|
+
Anonymous users are redirected to `LOGIN_URL`; authenticated users without permission get 403.
|
|
55
|
+
|
|
56
|
+
## Notifiers
|
|
57
|
+
|
|
58
|
+
The signal `snapnote.signals.ticket_created(sender, ticket, request_url)` is sent after commit.
|
|
59
|
+
A notifier is any callable; a failing one is logged and never breaks the report:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
# myproject/notifiers.py — Slack recipe
|
|
63
|
+
import requests
|
|
64
|
+
def slack(ticket, url):
|
|
65
|
+
requests.post(settings.SLACK_WEBHOOK, json={"text": f"New ticket: {ticket.title} {url}"}, timeout=5)
|
|
66
|
+
|
|
67
|
+
SNAPNOTE_NOTIFIERS = ["snapnote.notifiers.email", "myproject.notifiers.slack"]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Security and deployment notes
|
|
71
|
+
|
|
72
|
+
- **Screenshots can contain sensitive data.** Serve `MEDIA_ROOT` only to authenticated staff (or use a
|
|
73
|
+
private storage); do not expose it publicly. Purge old reports with
|
|
74
|
+
`python manage.py snapnote_purge --older-than 90` (`--dry-run` to preview).
|
|
75
|
+
- **Permissions-Policy**: the browser needs `display-capture=(self)`. With `display-capture=()`
|
|
76
|
+
(also sent by some proxies) capture never works and users get the upload fallback.
|
|
77
|
+
- **CSP**: allow `img-src blob: data:` and `style-src 'unsafe-inline'` (the Shadow DOM stylesheet
|
|
78
|
+
is inline) and `script-src` for the static bundle.
|
|
79
|
+
- Uploads are validated (PNG/JPEG/WebP only, no SVG), size/dimension limited and re-encoded with Pillow.
|
|
80
|
+
- `page_url` must be http(s) and is never rendered as a clickable link otherwise.
|
|
81
|
+
- The report endpoint uses Django's CSRF protection; the template tag sets the cookie.
|
|
82
|
+
- Capture uses `getDisplayMedia` (desktop browsers). On mobile the user attaches an image.
|
|
83
|
+
|
|
84
|
+
## Development
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
pip install -e . pytest pytest-django
|
|
88
|
+
pytest # SQLite; SNAPNOTE_TEST_DB=postgres for PostgreSQL
|
|
89
|
+
cd frontend && npm ci && npm run build # writes src/snapnote/static/snapnote/snapnote.js
|
|
90
|
+
npm test && npm run e2e
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The built `snapnote.js` is committed/packaged so installing the wheel never needs Node.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Browser smoke test. Run from frontend/: npm run e2e (needs the Python venv at ../.venv
|
|
2
|
+
// and a Chrome/Chromium; set CHROME_PATH or let Playwright use channel "chrome").
|
|
3
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
4
|
+
import { mkdtempSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { chromium } from 'playwright';
|
|
8
|
+
|
|
9
|
+
const root = new URL('../..', import.meta.url).pathname;
|
|
10
|
+
const python = join(root, '.venv/bin/python');
|
|
11
|
+
const dir = mkdtempSync(join(tmpdir(), 'snapnote-e2e-'));
|
|
12
|
+
const env = {
|
|
13
|
+
...process.env,
|
|
14
|
+
SNAPNOTE_E2E: '1',
|
|
15
|
+
SNAPNOTE_E2E_DB: join(dir, 'db.sqlite3'),
|
|
16
|
+
DJANGO_SETTINGS_MODULE: 'tests.testproject.settings',
|
|
17
|
+
PYTHONPATH: root,
|
|
18
|
+
};
|
|
19
|
+
const PORT = 8765;
|
|
20
|
+
const base = `http://127.0.0.1:${PORT}`;
|
|
21
|
+
|
|
22
|
+
execFileSync(python, ['-m', 'django', 'migrate', '-v0'], { env, cwd: root });
|
|
23
|
+
const server = spawn(python, ['-m', 'django', 'runserver', `${PORT}`, '--noreload'], {
|
|
24
|
+
env, cwd: root, stdio: 'ignore',
|
|
25
|
+
});
|
|
26
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
27
|
+
for (let i = 0; i < 50; i++) {
|
|
28
|
+
try { await fetch(base); break; } catch { await sleep(200); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const results = [];
|
|
32
|
+
const check = (name, ok) => { results.push([name, ok]); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}`); };
|
|
33
|
+
|
|
34
|
+
// Stubs getDisplayMedia with a canvas stream (the real share prompt can't be automated).
|
|
35
|
+
const STUB = `
|
|
36
|
+
navigator.mediaDevices.getDisplayMedia = async () => {
|
|
37
|
+
const c = document.createElement('canvas'); c.width = 640; c.height = 360;
|
|
38
|
+
const x = c.getContext('2d'); x.fillStyle = '#2b6cb0'; x.fillRect(0, 0, 640, 360);
|
|
39
|
+
setInterval(() => x.fillRect(0, 0, 640, 360), 50);
|
|
40
|
+
return c.captureStream(20);
|
|
41
|
+
};`;
|
|
42
|
+
|
|
43
|
+
async function scenario(browser, { capture }) {
|
|
44
|
+
const label = capture ? 'with capture' : 'without getDisplayMedia';
|
|
45
|
+
const context = await browser.newContext({ viewport: { width: 1000, height: 700 } });
|
|
46
|
+
const page = await context.newPage();
|
|
47
|
+
await page.addInitScript(capture ? STUB : "Object.defineProperty(navigator.mediaDevices, 'getDisplayMedia', { value: undefined });");
|
|
48
|
+
await page.goto(`${base}/e2e-login/`);
|
|
49
|
+
const fab = page.locator('#snapnote-root .snapnote-fab');
|
|
50
|
+
await fab.waitFor();
|
|
51
|
+
check(`${label}: button renders inside shadow DOM`, (await fab.count()) === 1);
|
|
52
|
+
await fab.click();
|
|
53
|
+
|
|
54
|
+
const title = `E2E ${label}`;
|
|
55
|
+
if (capture) {
|
|
56
|
+
await page.locator('.snapnote-editor').waitFor();
|
|
57
|
+
const canvas = page.locator('.snapnote-editor-canvas');
|
|
58
|
+
await canvas.waitFor();
|
|
59
|
+
const box = await canvas.boundingBox();
|
|
60
|
+
await page.mouse.move(box.x + 100, box.y + 100);
|
|
61
|
+
await page.mouse.down();
|
|
62
|
+
await page.mouse.move(box.x + 250, box.y + 180, { steps: 5 });
|
|
63
|
+
await page.mouse.up();
|
|
64
|
+
await page.getByRole('button', { name: 'Done' }).click();
|
|
65
|
+
check(`${label}: file input hidden when capture worked`, (await page.locator('input[type=file]').count()) === 0);
|
|
66
|
+
check(`${label}: annotations layer previewed`, (await page.locator('.snapnote-preview-annotations').count()) === 1);
|
|
67
|
+
} else {
|
|
68
|
+
await page.locator('input[type=file]').waitFor();
|
|
69
|
+
check(`${label}: file input shown`, true);
|
|
70
|
+
const { writeFileSync } = await import('node:fs');
|
|
71
|
+
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC', 'base64');
|
|
72
|
+
const file = join(dir, 'm.png'); writeFileSync(file, png);
|
|
73
|
+
await page.locator('input[type=file]').setInputFiles(file);
|
|
74
|
+
await page.locator('.snapnote-editor-canvas').waitFor();
|
|
75
|
+
await page.getByRole('button', { name: 'Done' }).click();
|
|
76
|
+
check(`${label}: uploaded image reaches the preview`, (await page.locator('.snapnote-preview img').count()) >= 1);
|
|
77
|
+
}
|
|
78
|
+
await page.locator('.snapnote-field input:not([type=file])').first().fill(title);
|
|
79
|
+
await page.getByRole('button', { name: 'Send' }).click();
|
|
80
|
+
await page.locator('.snapnote-toast').waitFor();
|
|
81
|
+
check(`${label}: success notice`, true);
|
|
82
|
+
|
|
83
|
+
await page.goto(`${base}/snapnote/`);
|
|
84
|
+
check(`${label}: ticket listed`, (await page.getByText(title).count()) === 1);
|
|
85
|
+
await page.getByText(title).click();
|
|
86
|
+
const imgs = await page.locator('.snapnote-shot-frame img').count();
|
|
87
|
+
check(`${label}: detail shows screenshot${capture ? ' + annotations' : ''}`, imgs === (capture ? 2 : 1));
|
|
88
|
+
if (capture) {
|
|
89
|
+
await page.locator('#snapnote-ann-toggle').uncheck();
|
|
90
|
+
check(`${label}: annotations switch hides layer`, !(await page.locator('.snapnote-annotations').isVisible()));
|
|
91
|
+
}
|
|
92
|
+
await context.close();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let browser;
|
|
96
|
+
try {
|
|
97
|
+
browser = await chromium.launch({
|
|
98
|
+
channel: process.env.CHROME_PATH ? undefined : 'chrome',
|
|
99
|
+
executablePath: process.env.CHROME_PATH,
|
|
100
|
+
});
|
|
101
|
+
await scenario(browser, { capture: true });
|
|
102
|
+
await scenario(browser, { capture: false });
|
|
103
|
+
} finally {
|
|
104
|
+
await browser?.close();
|
|
105
|
+
server.kill();
|
|
106
|
+
}
|
|
107
|
+
process.exit(results.every(([, ok]) => ok) ? 0 : 1);
|