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.
Files changed (66) hide show
  1. django_snapnote-0.1.1/.gitignore +55 -0
  2. django_snapnote-0.1.1/CHANGELOG.md +10 -0
  3. django_snapnote-0.1.1/LICENSE +21 -0
  4. django_snapnote-0.1.1/PKG-INFO +112 -0
  5. django_snapnote-0.1.1/README.md +93 -0
  6. django_snapnote-0.1.1/frontend/e2e/smoke.mjs +107 -0
  7. django_snapnote-0.1.1/frontend/package-lock.json +2964 -0
  8. django_snapnote-0.1.1/frontend/package.json +26 -0
  9. django_snapnote-0.1.1/frontend/src/AnnotationEditor.tsx +292 -0
  10. django_snapnote-0.1.1/frontend/src/App.tsx +225 -0
  11. django_snapnote-0.1.1/frontend/src/api.ts +35 -0
  12. django_snapnote-0.1.1/frontend/src/capture.ts +42 -0
  13. django_snapnote-0.1.1/frontend/src/drawing.test.ts +32 -0
  14. django_snapnote-0.1.1/frontend/src/drawing.ts +108 -0
  15. django_snapnote-0.1.1/frontend/src/icons.tsx +44 -0
  16. django_snapnote-0.1.1/frontend/src/main.tsx +31 -0
  17. django_snapnote-0.1.1/frontend/src/styles.css +70 -0
  18. django_snapnote-0.1.1/frontend/src/types.ts +39 -0
  19. django_snapnote-0.1.1/frontend/src/vite-env.d.ts +1 -0
  20. django_snapnote-0.1.1/frontend/tsconfig.json +14 -0
  21. django_snapnote-0.1.1/frontend/vite.config.ts +22 -0
  22. django_snapnote-0.1.1/pyproject.toml +44 -0
  23. django_snapnote-0.1.1/src/snapnote/__init__.py +1 -0
  24. django_snapnote-0.1.1/src/snapnote/admin.py +12 -0
  25. django_snapnote-0.1.1/src/snapnote/apps.py +14 -0
  26. django_snapnote-0.1.1/src/snapnote/checks.py +22 -0
  27. django_snapnote-0.1.1/src/snapnote/conf.py +25 -0
  28. django_snapnote-0.1.1/src/snapnote/forms.py +83 -0
  29. django_snapnote-0.1.1/src/snapnote/locale/es/LC_MESSAGES/django.mo +0 -0
  30. django_snapnote-0.1.1/src/snapnote/locale/es/LC_MESSAGES/django.po +186 -0
  31. django_snapnote-0.1.1/src/snapnote/management/__init__.py +0 -0
  32. django_snapnote-0.1.1/src/snapnote/management/commands/__init__.py +0 -0
  33. django_snapnote-0.1.1/src/snapnote/management/commands/snapnote_purge.py +26 -0
  34. django_snapnote-0.1.1/src/snapnote/middleware.py +32 -0
  35. django_snapnote-0.1.1/src/snapnote/migrations/0001_initial.py +92 -0
  36. django_snapnote-0.1.1/src/snapnote/migrations/__init__.py +0 -0
  37. django_snapnote-0.1.1/src/snapnote/models.py +120 -0
  38. django_snapnote-0.1.1/src/snapnote/notifiers.py +42 -0
  39. django_snapnote-0.1.1/src/snapnote/permissions.py +2 -0
  40. django_snapnote-0.1.1/src/snapnote/signals.py +5 -0
  41. django_snapnote-0.1.1/src/snapnote/static/snapnote/management.css +26 -0
  42. django_snapnote-0.1.1/src/snapnote/static/snapnote/snapnote.js +40 -0
  43. django_snapnote-0.1.1/src/snapnote/templates/snapnote/base.html +12 -0
  44. django_snapnote-0.1.1/src/snapnote/templates/snapnote/button.html +1 -0
  45. django_snapnote-0.1.1/src/snapnote/templates/snapnote/includes/status_badge.html +1 -0
  46. django_snapnote-0.1.1/src/snapnote/templates/snapnote/ticket_confirm_delete.html +12 -0
  47. django_snapnote-0.1.1/src/snapnote/templates/snapnote/ticket_detail.html +42 -0
  48. django_snapnote-0.1.1/src/snapnote/templates/snapnote/ticket_list.html +39 -0
  49. django_snapnote-0.1.1/src/snapnote/templatetags/__init__.py +0 -0
  50. django_snapnote-0.1.1/src/snapnote/templatetags/snapnote_tags.py +65 -0
  51. django_snapnote-0.1.1/src/snapnote/urls.py +14 -0
  52. django_snapnote-0.1.1/src/snapnote/views.py +123 -0
  53. django_snapnote-0.1.1/tests/__init__.py +0 -0
  54. django_snapnote-0.1.1/tests/conftest.py +76 -0
  55. django_snapnote-0.1.1/tests/helpers.py +13 -0
  56. django_snapnote-0.1.1/tests/test_button.py +49 -0
  57. django_snapnote-0.1.1/tests/test_management.py +102 -0
  58. django_snapnote-0.1.1/tests/test_report.py +139 -0
  59. django_snapnote-0.1.1/tests/testproject/__init__.py +0 -0
  60. django_snapnote-0.1.1/tests/testproject/hostapp/__init__.py +0 -0
  61. django_snapnote-0.1.1/tests/testproject/hostapp/apps.py +6 -0
  62. django_snapnote-0.1.1/tests/testproject/hostapp/models.py +22 -0
  63. django_snapnote-0.1.1/tests/testproject/settings.py +69 -0
  64. django_snapnote-0.1.1/tests/testproject/templates/alt_base.html +1 -0
  65. django_snapnote-0.1.1/tests/testproject/templates/host_base.html +1 -0
  66. 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);