Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Alejandro Alonso 2026-08-20 09:15:03 +02:00
commit 2dcf1a8a0a
63 changed files with 3206 additions and 811 deletions

View File

@ -1,27 +1,30 @@
Act as a senior software engineer and perform a thorough code review.
Act as a senior software engineer and perform a thorough review.
## Instructions
1. **Determine what is being reviewed** from the provided context:
- **If it is a plan** (implementation plan, design document, task breakdown) → follow the **Plan Review** path below.
- **If it is code** (diff, PR, code change) → follow the **Code Review** path below.
---
## Code Review Path
1. Load the **`code-review-and-quality`** skill — it defines the five axes, core principles (DRY, KISS, YAGNI), severity taxonomy, and output format.
2. Determine the diff or code to review from the provided context.
3. **Skip generated files, lockfile-only changes, and unrelated modifications** unless they introduce security risks.
4. Read the diff and the surrounding context for each changed file.
5. Review across all five axes: correctness, readability, architecture, security, performance.
6. Produce the review using this structure:
- **Summary**: One-paragraph overview of the change and its impact
- **Critical/High Findings**: Blockers that must be fixed (with file:line, severity, description, and proposed fix)
- **Other Findings**: Medium/Low issues and suggestions
- **Testing Recommendations**: Missing test coverage or test quality issues
- **Positive Observations**: What was done well (brief, specific)
- **Verdict**: Approve / Request Changes / Needs Discussion
7. For each finding:
2. Read `AGENTS.md` and follow its instructions for finding and reading all related testing documentation from memories before reviewing the code.
3. Determine the diff or code to review from the provided context.
4. **Skip generated files, lockfile-only changes, and unrelated modifications** unless they introduce security risks.
5. Read the diff and the surrounding context for each changed file.
6. Review across all five axes: correctness, readability, architecture, security, performance.
7. Produce the review using the **Code Review Format** below.
8. For each finding:
- State the severity (Critical / High / Medium / Low / Suggestion)
- Identify the file and line
- Describe failure circumstances
- **For Critical/High**: Provide a concrete fix with a code snippet showing the corrected code
- **For Medium/Low**: Describe the fix clearly; code snippet optional
- If multiple approaches exist, briefly note trade-offs
8. **Perform a second review pass if the change is complex:**
9. **Perform a second review pass if the change is complex:**
- **Complex indicators**: Critical/High findings, multiple files (>5), architectural changes, security-sensitive code, >300 lines changed
- **Skip for simple changes**: Typo fixes, formatting, small bug fixes (<50 lines), single-file changes with no findings
- Second pass checks:
@ -30,45 +33,76 @@ Act as a senior software engineer and perform a thorough code review.
- Remove false positives: Discard findings that aren't real issues
- Verify fixes: Are the proposed solutions actually correct and complete?
---
## Plan Review Path
1. Load the **`plan-review`** skill — it defines the six axes, severity taxonomy, and output format.
2. Read the full plan from the provided context.
3. Review across all six axes: completeness, task quality, architecture & sequencing, risk coverage, actionability, and proposed code quality (if the plan includes implementation details).
4. Produce the review using the **Plan Review Format** below.
5. For each finding:
- State the severity (Critical / Required / Nit / Optional / FYI)
- Identify the section or task it refers to
- Describe the gap or problem
- **For Critical/Required**: Propose a concrete fix or addition
- **For Nit/Optional**: Describe the improvement; concrete text optional
6. **Perform a second review pass if the plan is complex:**
- **Complex indicators**: Critical findings, >10 tasks, migrations or breaking changes, security-sensitive features
- **Skip for simple plans**: 12 tasks, no risks, no code proposals
- Second pass checks:
- Validate severity assignments
- Catch missed gaps: edge cases, missing dependencies, unaddressed risks
- Remove false positives
- Verify proposed remedies are actionable
---
## Strong Rules
1. Do not invent problems. Every finding must be real and actionable.
2. Do not modify any code and do not create a commit — this command only reviews.
3. Be specific and constructive. "This could be better" is not helpful — explain why and how.
4. Prioritize by impact. One structural issue outweighs ten nits.
5. If tests are missing for new functionality, flag it as High severity.
5. Missing tests are an issue, not a suggestion. If tests are missing or inadequate for new functionality, report it as a severity-tagged finding in the findings sections below — High severity (code) or Required (plan) — never as a recommendation.
## Context
$ARGUMENTS
## Expected Format
## Expected Format — Code Review
```
## Review Summary
[1-2 sentences on what the change does and overall assessment]
## Critical/High Findings
### [Severity] file.ts:123
**Issue**: [Description of the problem]
**Impact**: [What could go wrong]
**Impact**: [What could go wrong if this is not fixed]
**Fix**:
```[language]
````[language]
// Current code
[problematic code]
// Fixed code
[corrected code]
```
[Optional: note trade-offs if multiple approaches exist]
````
### [Severity] file.ts:456
**Issue**: [Description of the problem]
**Impact**: [What could go wrong if this is not fixed]
**Fix**: [Clear description of the fix; code snippet if it clarifies]
## Other Findings
### [Severity] file.ts:456
**Issue**: [Description]
**Fix**: [Clear description; code snippet optional]
## Testing Recommendations
[List specific test cases that should be added]
### [Severity] file.ts:789
**Issue**: [Description]
**Impact**: [Minor consequence or risk]
**Fix**: [Clear description; code snippet optional]
## Positive Observations
[2-3 specific things done well]
@ -77,3 +111,28 @@ $ARGUMENTS
[Approve / Request Changes / Needs Discussion]
[If Request Changes: list the must-fix items]
```
## Expected Format — Plan Review
```
## Review Summary
[1-2 sentences on the plan's goal and overall assessment]
## Critical/Required Findings
### [Severity] [Section or Task N]
**Issue**: [Description of the gap or problem]
**Impact**: [What could go wrong during implementation]
**Proposed fix**: [Concrete addition or change to the plan]
## Other Findings
### [Severity] [Section or Task N]
**Issue**: [Description]
**Proposed fix**: [Clear description; concrete text optional]
## Strengths
[2-3 specific things done well in the plan]
## Verdict
[Approve / Request Changes / Needs Discussion]
[If Request Changes: list the must-fix items]
```

View File

@ -0,0 +1,315 @@
---
name: plan-review
description: Reviews implementation plans for quality, completeness, and actionability. Use after a plan is produced by the planner skill, before starting implementation. Use when evaluating a plan written by yourself, another agent, or a human.
---
# Plan Review
## Overview
Multi-dimensional plan review with quality gates. Every plan gets reviewed before implementation starts — no exceptions. Review covers six axes: completeness, task quality, architecture & sequencing, risk coverage, actionability, and proposed code quality.
**The approval standard:** Approve a plan when it is specific enough that a skilled implementer could execute it without guessing, the task ordering is sound, and risks are acknowledged. Perfect plans don't exist — the goal is confidence that implementation won't derail. Don't block a plan because it isn't exactly how you would have structured it. If it's executable and well-organized, approve it.
## When to Use
- After the planner skill produces a plan
- Before starting implementation on any non-trivial task
- When reviewing a plan written by another agent or a human
- When a plan feels too large, vague, or risky to start
**Do NOT use for:** Single-file changes with obvious scope, or when the task is trivial enough to just do.
## The Six-Axis Review
Every plan gets evaluated across these dimensions:
### 1. Completeness
Does the plan cover everything needed to implement successfully?
- Is the **context** clear? (What problem, why now, what's the goal?)
- Are **affected modules** identified with paths?
- Are **architecture decisions** documented with rationale?
- Is there a **testing strategy**?
- Are **verification commands** explicit (not "run the tests")?
- Are **open questions** listed (not buried in someone's head)?
- Is there a **parallelization** assessment for multi-task plans?
**Missing any of these is a gap, not a nit.**
### 2. Task Quality
Are the tasks well-defined and independently executable?
- Does every task have **acceptance criteria**? (Testable, not vague)
- Does every task have **verification steps**?
- Are tasks **sized appropriately**? (XSM is ideal, L is acceptable, XL must be split)
- Are **dependencies** between tasks explicitly stated?
- Are **files likely touched** listed?
- Is each task a **single, self-contained change**? (Not "implement the whole feature")
- Could a skilled implementer pick up any task and execute it without asking clarifying questions?
### 3. Architecture & Sequencing
Is the plan structured so implementation flows correctly?
- Does implementation order follow the **dependency graph** (foundations first)?
- Are tasks **vertically sliced** (feature paths) rather than horizontally layered?
- Does each task leave the system in a **working state**?
- Are there **checkpoints** between major phases?
- Are **high-risk tasks early** (fail fast)?
- Is the total plan a reasonable number of tasks? (More than ~15 tasks suggests the scope should be split into multiple plans)
### 4. Risk Coverage
Are the hard parts acknowledged and mitigated?
- Are **edge cases** identified?
- Are **breaking changes** or **migration concerns** noted?
- Are **security implications** considered?
- Are **performance implications** considered?
- Are **external dependencies** or integration risks flagged?
- Is there a plan for **rollback** if something goes wrong?
- Are **data integrity** risks addressed (what happens if a migration fails mid-way)?
### 5. Actionability
Can an implementer actually execute this?
- Are **file paths** specific (not "update the relevant files")?
- Are **function/method names** mentioned where applicable?
- Are **verification commands** copy-pasteable (not "run the linter")?
- Are **test commands** project-specific (not generic)?
- Is the **code shape** described where the implementation isn't obvious?
- Are **conventions** referenced (naming, patterns, existing utilities to reuse)?
- Does the plan reference **existing code** the implementer should read first?
### 6. Proposed Code Quality *(when the plan includes implementation details)*
If the plan proposes code shapes, function signatures, data structures, or API designs, evaluate those proposals against `code-review-and-quality` criteria:
- **Correctness:** Do the proposed types/signatures handle edge cases (null, empty, boundaries)?
- **Readability:** Are proposed names descriptive and consistent with project conventions?
- **Architecture:** Do proposed abstractions follow existing patterns? Are they justified (not over-engineered)?
- **Security:** Do proposed APIs validate input at boundaries? Any injection/XSS vectors in the design?
- **Performance:** Do proposed data structures avoid N+1 patterns? Any unbounded operations in the design?
**When to apply:** Only when the plan includes specific code snippets, type definitions, API contracts, or function signatures. Plans that only describe "what" without showing "how" skip this axis.
## Structural Remedies
When you flag a structural problem in a plan, propose the fix — not just the problem:
- **A task is too large (XL):** Split it into vertical slices. Each slice should be independently testable.
- **Missing acceptance criteria:** Draft 23 specific, testable conditions for the task.
- **Wrong sequencing:** Identify the dependency and propose the correct order.
- **No checkpoints:** Suggest where checkpoints should go (typically after every 23 tasks).
- **Vague verification:** Replace "run tests" with the actual project command.
- **Horizontal slicing:** Restructure into vertical feature paths.
- **Missing risk section:** Draft the risks you can identify from the plan content.
Prefer the remedy that makes the plan immediately actionable over one that just flags the gap.
## Plan Sizing
Plans should be scoped to a single deliverable:
```
15 tasks → Good. A focused feature or bug fix.
610 tasks → Acceptable for a moderate feature.
1115 tasks → Large. Consider splitting into phases.
15+ tasks → Too large. Split into multiple plans.
```
**What counts as "one plan":** A self-contained set of changes that delivers a single coherent capability. If you can describe the goal in one sentence, it's one plan.
## Categorize Findings
Label every comment with its severity so the author knows what's required vs optional:
| Prefix | Meaning | Author Action |
|--------|---------|---------------|
| *(no prefix)* | Required change | Must address before implementation starts |
| **Critical:** | Blocks implementation | Missing security consideration, data integrity risk, fundamentally wrong approach |
| **Nit:** | Minor, optional | Author may ignore — wording, formatting |
| **Optional:** / **Consider:** | Suggestion | Worth considering but not required |
| **FYI** | Informational only | No action needed — context for future reference |
**Lead with what matters.** Order findings by leverage: missing risks and wrong sequencing first, then task quality gaps, then completeness, then nits. If you have one critical sequencing problem and ten nits, the sequencing problem *is* the review.
## Review Process
### Step 1: Understand the Goal
Before evaluating structure, understand intent:
```
- What is this plan trying to accomplish?
- What problem does it solve?
- What does "done" look like?
```
### Step 2: Check Completeness First
Scan for missing sections before diving into content:
```
- Context present?
- Affected modules listed?
- Architecture decisions documented?
- Risks acknowledged?
- Testing strategy defined?
- Verification commands explicit?
```
### Step 3: Review Task Quality
Walk through each task:
```
For each task:
1. Can I tell exactly what to build?
2. Are acceptance criteria specific and testable?
3. Is the size reasonable (not XL)?
4. Are dependencies clear?
5. Would I know which files to touch?
```
### Step 4: Validate Sequencing
Check the dependency graph:
```
- Are foundations built first?
- Does each task leave the system working?
- Are checkpoints placed correctly?
- Are high-risk items early?
- Is it vertically sliced?
```
### Step 5: Assess Actionability
Put yourself in the implementer's shoes:
```
- Could I pick up task 1 and start coding without asking any questions?
- Are the verification commands copy-pasteable?
- Are file paths and function names specific?
- Is existing code referenced where I'd need to read it?
```
### Step 6: Verify the Verification Story
Check that the plan can actually confirm it worked:
```
- What tests should pass after implementation?
- What build/compile commands are relevant?
- What manual checks are needed?
- How do we know the feature works end-to-end?
```
### Step 7: Evaluate Proposed Code Quality *(if applicable)*
If the plan includes code snippets, types, or API designs:
```
- Load code-review-and-quality skill for criteria
- Check proposed signatures for edge cases
- Verify naming follows project conventions
- Confirm abstractions follow existing patterns
- Scan for security vectors in proposed APIs
- Check for performance issues in proposed data structures
```
## Review Checklist
```markdown
## Review: [Plan title]
### Completeness
- [ ] Context explains the problem and goal
- [ ] Affected modules are listed with paths
- [ ] Architecture decisions have rationale
- [ ] Testing strategy is defined
- [ ] Verification commands are explicit and project-specific
- [ ] Open questions are listed
### Task Quality
- [ ] Every task has acceptance criteria
- [ ] Every task has verification steps
- [ ] Tasks are sized XSM (L acceptable, XL must be split)
- [ ] Task dependencies are stated
- [ ] Files likely touched are listed
### Architecture & Sequencing
- [ ] Order follows dependency graph (foundations first)
- [ ] Vertically sliced (not horizontal layers)
- [ ] Each task leaves system working
- [ ] Checkpoints exist between phases
- [ ] High-risk tasks are early
### Risk Coverage
- [ ] Edge cases identified
- [ ] Breaking changes / migrations noted
- [ ] Security implications considered
- [ ] Performance implications considered
- [ ] Rollback strategy exists (if applicable)
### Actionability
- [ ] File paths are specific
- [ ] Verification commands are copy-pasteable
- [ ] Existing code to read is referenced
- [ ] Conventions and patterns are noted
### Proposed Code Quality *(if plan includes implementation details)*
- [ ] Proposed types/signatures handle edge cases
- [ ] Proposed names follow project conventions
- [ ] Proposed abstractions follow existing patterns
- [ ] No security vectors in proposed APIs
- [ ] No performance issues in proposed structures
### Verdict
- [ ] **Approve** — Ready to implement
- [ ] **Request changes** — Gaps must be addressed
```
## Common Rationalizations
| Rationalization | Reality |
|---|---|
| "I'll figure out the details during implementation" | That's how you discover blocking dependencies mid-task. Surface them now. |
| "The tasks are obvious, no need for criteria" | Write them anyway. Explicit criteria surface hidden assumptions. |
| "It's just a small feature, it doesn't need a plan" | Small features have edge cases too. 3 tasks with criteria takes 5 minutes. |
| "The plan is good enough" | "Good enough" without acceptance criteria means the implementer defines "done" — and they might define it differently. |
| "I'll add verification steps later" | Later never comes. The plan is the contract — define verification now. |
| "Risks are minimal" | Every change has risks. If you can't name them, you haven't thought about them. |
| "The file paths are obvious" | They're obvious to the author. The implementer might not know the codebase. |
| "The code in the plan is fine, it'll get reviewed later" | Plan-level code review catches design problems before implementation — fixing them after coding is more expensive. |
## Red Flags
- No acceptance criteria on any task
- Tasks that say "implement the feature" without specifics
- No verification steps anywhere in the plan
- All tasks are XL-sized
- No checkpoints between phases
- Dependency order isn't considered (e.g., API handler before domain model)
- No testing strategy
- Verification commands are generic ("run tests") instead of project-specific
- Plan has 20+ tasks (scope too large for one plan)
- No risk section on a plan with migrations, breaking changes, or security implications
- Horizontal slicing (all domain, then all services, then all API)
- File paths are vague ("update the relevant files")
- Missing open questions section despite stated unknowns
- Proposed code ignores project conventions or existing patterns
- Proposed types use gratuitous `any`/`unknown`/optional without justification
- Proposed APIs don't validate input at boundaries
## See Also
- For producing plans, use the `planner` skill
- For reviewing implemented code, use `code-review-and-quality` — also the criteria source for axis 6
- For security-specific concerns, see `security-and-hardening`
- For testing strategy guidance, see `testing`

View File

@ -5,7 +5,8 @@ Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; m
## Focused memories
- RPC, DB helpers, workers, cron: `mem:backend/rpc-db-worker-subtleties`
- HTTP sessions, config, storage, media, file data persistence: `mem:backend/http-storage-filedata-subtleties`
- Storage abstraction, logical buckets, object lifecycle, deduplication, access, and garbage collection: `mem:backend/storage`.
- HTTP sessions, config, media processing, and file data persistence: `mem:backend/http-storage-filedata-subtleties`.
- Auth flows, permission model, teams, projects, invitations, comments, webhooks, audit: `mem:backend/auth-permissions-product-domains`
- Services, task-queue/Pub-Sub topology constraints -> `mem:prod-infra/core`.
@ -107,4 +108,3 @@ IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory. J
* **Isolated run:** `clojure -M:dev:test --focus backend-tests.my-ns-test` for a specific test namespace.
* **Regression run:** `clojure -M:dev:test` to ensure no regressions in related functional areas.
* **Principles:** Cross-cutting testing principles, anti-patterns, and verification checklist: `mem:testing`.

View File

@ -14,10 +14,7 @@
## Storage and media
- Storage has a fixed valid bucket set. Backends are `:fs` and `:s3`; default backend comes from deprecated `assets-storage-backend` only when present, otherwise `objects-storage-backend`, defaulting to `:fs`.
- `put-object!` creates the DB `storage_object` row before writing backend content. Backend writes happen only for newly created rows, so deduplication can skip object writes.
- Deduplication only applies when requested, when the content can provide a hash, and when bucket metadata is present. Reads exclude soft-deleted storage rows.
- `sto/resolve` can reuse the current DB connection via `::db/reuse-conn true`; preserve this in transaction-sensitive code.
- Storage abstraction, backend configuration, logical buckets, object lifecycle, deduplication, access rules, and garbage collection: `mem:backend/storage`.
- SVG validation strips DOCTYPE and uses secure SAX parsing. Basic SVG info falls back to 100x100 dimensions when width/height/viewBox are missing.
- Raster metadata is shell-derived with ImageMagick `identify`, verifies detected MIME against the supplied MIME, and swaps dimensions for EXIF orientations 6/8.
- Remote image download requires 2xx status, `content-length`, a known MIME, and size under the configured maximum before writing the temp file; mismatched byte count is an internal error.
@ -28,4 +25,4 @@
- File data backends are `legacy-db`, `db`, and `storage`. The storage backend keeps encoded file data in storage bucket `file-data`; the DB row stores metadata with `storage-ref-id` and nil data.
- `fdata/upsert!` touches any storage object referenced by incoming metadata before storing the new row/blob.
- Pointer-map fragments are persisted separately as type `fragment`, and only modified pointer maps are written.
- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders.
- `fdata/realize` combines pointer realization and object-map realization. Use it before operations that need complete in-memory file data instead of pointer placeholders.

View File

@ -0,0 +1,83 @@
# Backend Storage
## Abstraction
- `app.storage` stores binary objects.
- Each object has a `storage_object` database row.
- The row stores the UUID, size, backend, timestamps, and Transit metadata.
- The backend stores the binary content.
- Supported backends are `:fs` and `:s3`.
- FS uses one root directory and a UUID-derived path.
- S3 uses one configured bucket and an optional prefix.
- A Penpot bucket is metadata. It is not an S3 bucket or a filesystem directory.
- FS and S3 use the same UUID-derived object path. The bucket does not change the path.
- `PENPOT_OBJECTS_STORAGE_*` configures the current object backend.
- Deprecated asset-storage config keys remain supported for migration.
- Database rows keep the backend name. Keep the legacy `:assets-fs` and `:assets-s3` aliases.
## Object Lifecycle
- `put-object!` creates the database row before it writes backend content.
- Backend content is written only when the row is new.
- A failed backend write can leave an unreferenced database row.
- Callers often set `:touched-at` so garbage collection can remove such rows.
- `get-object` excludes rows with `deleted_at`.
- Existing object values can remain readable until physical deletion.
- `:expired-at` blocks reads after the expiration time.
- `del-object!` sets `deleted_at`. It does not remove backend content.
- `storage-gc-deleted` removes the database row and backend content after the deletion delay.
- `storage-gc-touched` finds references before it sets `deleted_at`.
- `objects-gc` removes deleted domain rows and touches their storage object IDs.
- Use `::db/reuse-conn true` with `sto/resolve` inside a database transaction.
## Deduplication
- Deduplication requires `::sto/deduplicate?`, a content hash, and bucket metadata.
- The lookup matches hash, bucket, backend, and `deleted_at IS NULL`.
- The lookup does not include file ID, profile ID, team ID, or organization ID.
- Objects can therefore share content across users and files within one bucket.
- Deleted objects are not reused.
- `tempfile` objects never use deduplication, even when the caller requests it.
- Use `sto/wrap-with-hash` when the caller already calculated the content hash.
## Bucket Rules
| Bucket | Content and references | Dedup | Direct `/assets/by-id` access | Cleanup |
| --- | --- | --- | --- | --- |
| `file-media-object` | Original file images and generated media thumbnails. References: `file_media_object.media_id` and `thumbnail_id`. | Yes | Public | Reference scan. |
| `team-font-variant` | Font variants in `team_font_variant`. References: `woff1_file_id`, `woff2_file_id`, `otf_file_id`, and `ttf_file_id`. | Yes | Public | Reference scan. |
| `file-object-thumbnail` | Frame and component thumbnails in `file_tagged_object_thumbnail.media_id`. | Yes | Public | Reference scan. |
| `file-thumbnail` | File grid thumbnails in `file_thumbnail.media_id`. | Yes | Authentication required | Reference scan. |
| `profile` | User and team profile photos. References: `profile.photo_id` and `team.photo_id`. | Yes | Authentication required | Reference scan. |
| `organization` | Organization logos uploaded by the Nitrate management API. | Yes | Public | No reference scan. A touched object is deleted. |
| `tempfile` | Export files, chunked-upload chunks, and temporary font downloads. | No | Authentication required | No reference scan. A touched object uses a two-hour deletion delay. |
| `file-data` | Encoded file data when `file-data-backend` is `storage`. Reference metadata has `storage-ref-id`, `file-id`, and the `file_data` row ID. | Yes | Authentication required | Reference scan. |
| `file-data-fragment` | Compatibility value for file-data fragments. The current backend has no dedicated producer for this bucket. | No current write semantics | Public | No touched-object collector case. |
| `file-change` | Compatibility value for file changes. Current snapshots store data in `file_data`, not this bucket. | No current write semantics | Authentication required | No touched-object collector case. |
- The valid bucket set lives in `app.storage/valid-buckets`.
- `file-media-object` is the default bucket for old rows without bucket metadata.
- Do not assign a new bucket without adding its access and cleanup behavior.
- The touched-object collector raises an internal error for an unknown bucket.
- It supports `file-media-object`, `team-font-variant`, `file-object-thumbnail`, `file-thumbnail`, `profile`, `file-data`, `tempfile`, and `organization`.
- It does not support `file-data-fragment` or `file-change`.
## Access Rules
- `app.http.assets` decides direct object authentication from the bucket.
- Public buckets are `file-media-object`, `file-object-thumbnail`, `team-font-variant`, `file-data-fragment`, and `organization`.
- Other valid buckets require a session or access-token profile ID.
- File-media routes also require file read permission.
- Non-public direct responses set `content-disposition: attachment`.
- FS responses use `x-accel-redirect` for the configured asset path.
- S3 responses use a presigned URL and an HTTP redirect.
## File Data
- `file-data-backend` accepts `legacy-db`, `db`, or `storage`.
- `legacy-db` stores main data in `file.data` and snapshots in `file_change.data`.
- `db` stores encoded data in `file_data.data`.
- `storage` stores encoded data in storage subsystem with `file-data` bucket and keeps `data` nil in `file_data` table.
- The `file_data.metadata.storage-ref-id` value points to the storage object.
- `fdata/upsert!` touches a storage object from incoming metadata before it stores the new row.
- File snapshots use `file_data` for snapshot data and `file_change` for snapshot metadata.

View File

@ -6,7 +6,7 @@ Backend (`app.config`, `PENPOT_*` env vars) is parameterized; deployments choose
- **PostgreSQL**: durable store. Profiles, teams, files, sessions, audit, `storage_object` metadata, the `task` queue, `scheduled_task` cron registry, migrations. File-data also lives here when the file-data backend is `legacy-db`/`db`. One shared DB across all backends.
- **Redis (Valkey-compatible)**: per-backend message bus and cache. Concrete uses: msgbus Pub/Sub for collaborative-editing broadcasts and team/profile-org notifications fired by RPC handlers (`app.rpc.notifications`, `files_update`, `teams`, `websocket`); file-summary cache gated by `enable-redis-cache`; rate-limit counters; and the dispatcher→runner work hand-off list `penpot.worker.queue:<tenant>:<queue>`. `PENPOT_REDIS_URI`.
- **Object storage**: backends `:s3` and `:fs`. S3 in prod; devenv uses MinIO. Holds uploaded media, file-data when the file-data backend is `storage`, exports. Backend-side details (resolve, dedup, bucket set, file-data backends): `mem:backend/http-storage-filedata-subtleties`.
- **Object storage**: backends `:s3` and `:fs`. S3 in prod; devenv uses MinIO. Holds uploaded media, file-data when the file-data backend is `storage`, exports. Backend-side details (resolve, dedup, bucket set, object lifecycle, and file-data backends): `mem:backend/storage`.
- **SMTP mailer**: invitations, password resets, email verification (sent via the `:sendmail` worker task).
- **LDAP** (optional auth provider): helpers in `app.auth.*`, gated by `enable-login-with-ldap`.
@ -30,4 +30,4 @@ Penpot in production lives with both: horizontal-scale deployments accept "exact
## See also
- Devenv composition and the ws0-only worker placement: `mem:devenv/core`.
- Storage backend resolution, dedup, file-data lifecycle: `mem:backend/http-storage-filedata-subtleties`.
- Storage backend resolution, dedup, bucket behavior, object lifecycle, and file-data lifecycle: `mem:backend/storage`.

View File

@ -9,6 +9,7 @@ repository via GraphQL and REST APIs through the authenticated `gh` CLI.
- Finding issues with no milestone.
- Fetching PR details by number or by milestone.
- Comparing milestone issues against CHANGES.md to find missing entries.
- Listing or inspecting GitHub Security Advisories (GHSA).
## Prerequisites
@ -72,6 +73,30 @@ python3 scripts/gh.py prs --milestone "2.16.0" --state all
**Output**: JSON array to stdout; progress to stderr.
### `advisories`
List or inspect GitHub Security Advisories for the repository.
```bash
# List all advisories (summary view)
python3 scripts/gh.py advisories
# Filter by severity
python3 scripts/gh.py advisories --severity critical
# Filter by state
python3 scripts/gh.py advisories --state triage
# Get full detail for a single advisory
python3 scripts/gh.py advisories GHSA-xvj6-fh9w-gjw7
```
**Summary output fields**: ghsa_id, cve_id, severity, cvss_score, state, summary, cwes, published_at, closed_at, url.
**Detail output** (single advisory) adds: description, vulnerabilities (package, version ranges), credits, timestamps.
**Output**: JSON to stdout; progress to stderr.
## Key principles
- All output is JSON — pipe into `jq` or other tools for further processing.

View File

@ -34,6 +34,13 @@ Skipping this step is the #1 cause of incorrect or incomplete work.
---
## Auto-triggers
- **Security advisory URL pasted** — When the user pastes a URL matching
`github.com/penpot/penpot/security/advisories/GHSA-*`, extract the GHSA ID
from the URL and run `python3 scripts/gh.py advisories <GHSA-ID>` to fetch
full advisory details before proceeding.
## Writing Rules
Use the `ste` skill when the user explicitly requests STE, `/ste`, or ASD-STE100.
@ -119,4 +126,5 @@ precision while maintaining a strong focus on maintainability and performance.
- `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines.
- `scripts/check-fmt-clj` — Check Clojure formatting without modifying files.
- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `scripts/ci --help`.
- `scripts/gh.py` — Multi-purpose GitHub CLI helper. Subcommands: `issues` (list issues in a milestone), `prs` (fetch PR details), `advisories` (list/inspect security advisories). See `python3 scripts/gh.py --help`.

View File

@ -771,6 +771,82 @@
;; ORG SSO HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- organization-sso-oauth-failure-reason
[error]
(case (d/name error)
"access_denied" "access-denied"
("temporarily_unavailable" "server_error") "provider-unavailable"
("invalid_request" "unauthorized_client" "invalid_scope") "invalid-configuration"
"provider-error"))
(defn- organization-sso-exception-failure-reason
[cause]
(let [data (ex-data cause)
status (or (:response-status data)
(:response-status-code data)
(:http-status data))
network-error?
(loop [current cause]
(cond
(nil? current)
false
(or (instance? java.net.ConnectException current)
(instance? java.net.UnknownHostException current)
(instance? java.net.http.HttpTimeoutException current)
(instance? javax.net.ssl.SSLException current))
true
(identical? current (ex-cause current))
false
:else
(recur (ex-cause current))))]
(if (or network-error?
(and (number? status) (<= 500 status 599)))
"provider-unavailable"
(case (:code data)
:unable-to-fetch-access-token "token-exchange-failed"
:unable-to-retrieve-user-info "user-info-failed"
:incomplete-user-info "incomplete-user-info"
:invalid-sso-config "invalid-configuration"
:unable-to-fetch-sso-jwks "provider-unavailable"
:unable-to-auth "access-denied"
"unexpected-error"))))
(defn- submit-organization-sso-auth-event
[cfg request profile-id organization-id name & {:keys [failure-reason]}]
(audit/submit cfg {:type "action"
:name name
:profile-id profile-id
:ip-addr (inet/parse-request request)
:props (d/without-nils
{:organization-id organization-id
:failure-reason failure-reason})
:context (audit/prepare-context-from-request request)}))
(defn submit-organization-sso-auth-started-event
[cfg request profile-id organization-id]
(submit-organization-sso-auth-event
cfg request profile-id organization-id "organization-sso-auth-started"))
(defn submit-organization-sso-auth-failed-event
[cfg request profile-id organization-id cause]
(submit-organization-sso-auth-event
cfg request profile-id organization-id "organization-sso-auth-failed"
:failure-reason (organization-sso-exception-failure-reason cause)))
(defn- submit-organization-sso-oauth-failed-event
[cfg request state-token error]
(try
(let [state (tokens/verify cfg {:token state-token :iss "oidc"})]
(when (:dest-url state)
(submit-organization-sso-auth-event
cfg request (some-> (session/get-session request) :profile-id)
(:organization-id state) "organization-sso-auth-failed"
:failure-reason (organization-sso-oauth-failure-reason error))))
(catch Exception _ nil)))
(defn- non-blank-uri
[value]
(when-not (str/blank? value) value))
@ -910,6 +986,8 @@
(let [props (-> (or (:props session) {})
(update :sso assoc organization-id exp))]
(session/update-session (::session/manager cfg) (assoc session :props props))))
(submit-organization-sso-auth-event
cfg request (:profile-id session) organization-id "organization-sso-auth-succeeded")
(redirect-response dest-url))
(catch Throwable cause
(let [{:keys [code]} (ex-data cause)]
@ -922,6 +1000,9 @@
(l/err :hint "unexpected error on organization sso callback"
:organization-id (:organization-id state)
:cause cause))))
(submit-organization-sso-auth-failed-event
cfg request (some-> (session/get-session request) :profile-id)
(:organization-id state) cause)
(let [organization-id (:organization-id state)
organization-name (:name (nitrate/call cfg :get-organization-summary {:organization-id organization-id}))]
(redirect-with-organization-sso-error
@ -932,7 +1013,9 @@
(defn- callback-handler
[cfg {:keys [params] :as request}]
(if-let [error (get params :error)]
(redirect-with-error "unable-to-auth" error)
(do
(submit-organization-sso-oauth-failed-event cfg request (:state params) error)
(redirect-with-error "unable-to-auth" error))
(try
(let [code (get params :code)
state (get params :state)

View File

@ -36,6 +36,16 @@
(def ^:private filter-auth-events
#{"login-with-oidc" "login-with-password" "register-profile" "update-profile"})
(def ^:private organization-sso-failure-reasons
#{"access-denied"
"provider-unavailable"
"invalid-configuration"
"provider-error"
"token-exchange-failed"
"user-info-failed"
"incomplete-user-info"
"unexpected-error"})
(def ^:private safe-backend-context-keys
#{:version
:initiator
@ -297,6 +307,14 @@
(defn filter-telemetry-props
[{:keys [source name props type] :as params}]
(cond
(and (= source "backend")
(= name "organization-sso-auth-failed"))
(let [props' (into {} xf:filter-telemetry-props props)
props' (cond-> props'
(contains? organization-sso-failure-reasons (:failure-reason props))
(assoc :failure-reason (:failure-reason props)))]
(assoc params :props props'))
(or (and (= source "frontend")
(= type "identify"))
(and (= source "backend")

View File

@ -7,6 +7,7 @@
(ns app.loggers.mattermost
"A mattermost integration for error reporting."
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.pprint :as pp]
@ -25,7 +26,7 @@
(defn- send-mattermost-notification!
[cfg {:keys [id] :as report}]
(let [type (get report :type)
text (str "#" type " | " (get report :hint) "\n"
text (str "#" type " | " (d/escape-markdown (get report :hint)) "\n"
(when id
(str (u/join (cf/get :public-uri) "/dbg/error/" id) " "))
@ -38,7 +39,7 @@
"- tenant: #" (:tenant report) "\n"
"- origin: #" (:origin report) "\n"
(when-let [href (get report :href)]
(str "- href: `" href "`\n"))
(str "- href: `" (d/escape-markdown href) "`\n"))
(when-let [version (get report :frontend-version)]
(str "- frontend-version: `" version "`\n"))
(when-let [version (get report :backend-version)]

View File

@ -106,7 +106,11 @@
(try
(case (int version)
1 (bf.v1/import-files! cfg)
3 (bf.v3/import-files! cfg))
3 (bf.v3/import-files! cfg)
(throw (ex-info (str "Unsupported binfile version: " version)
{:type :validation
:code :unsupported-version
:version version})))
(finally
(when owned?
(fs/delete input-path))))]
@ -124,7 +128,7 @@
[:name [:or [:string {:max 250}]
[:map-of ::sm/uuid [:string {:max 250}]]]]
[:project-id ::sm/uuid]
[:version {:optional true} ::sm/int]
[:version {:optional true} [:enum 1 3]]
[:file {:optional true} media.v/schema:upload]
[:upload-id {:optional true} ::sm/uuid]]
[:fn {:error/message "one of :file or :upload-id is required"}
@ -149,21 +153,28 @@
[:import-binfile/global]]}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version upload-id] :as params}]
(projects/check-edition-permissions! pool profile-id project-id)
(let [version (or version 3)
(let [params (if (some? upload-id)
(let [file (db/tx-run! cfg media-cmd/assemble-chunks profile-id upload-id)]
(assoc params :file file))
params)
version (or version
(case (bfc/parse-file-format (-> params :file :path))
:binfile-v1 1
:binfile-v3 3))
params (-> params
(assoc :profile-id profile-id)
(assoc :version version))
params
(if (some? upload-id)
(let [file (db/tx-run! cfg media-cmd/assemble-chunks upload-id)]
(assoc params :file file))
params)
manifest
(case (int version)
1 nil
3 (bf.v3/get-manifest (-> params :file :path)))]
3 (bf.v3/get-manifest (-> params :file :path))
(throw (ex-info (str "Unsupported binfile version: " version)
{:type :validation
:code :unsupported-version
:version version})))]
(with-meta
(sse/response (partial import-binfile cfg params))

View File

@ -7,6 +7,8 @@
(ns app.rpc.commands.files-share
"Share link related rpc mutation methods."
(:require
[app.binfile.common :as bfc]
[app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.common.uuid :as uuid]
[app.db :as db]
@ -66,5 +68,16 @@
[{:keys [::db/conn]} {:keys [::rpc/profile-id id] :as params}]
(let [slink (db/get-by-id conn :share-link id)]
(files/check-edition-permissions! conn profile-id (:file-id slink))
;; Verify caller owns this specific share-link, OR has admin access.
;; Note: :is-admin already includes :is-owner (see bfc/get-file-permissions),
;; so we only need to check :is-admin here.
(let [perms (bfc/get-file-permissions conn profile-id (:file-id slink))]
(when-not (or (= (:owner-id slink) profile-id)
(:is-admin perms))
(ex/raise :type :authorization
:code :not-share-link-owner
:hint "You can only delete share-links you created")))
(db/delete! conn :share-link {:id id})
nil))

View File

@ -118,10 +118,10 @@
"Assembles each chunked-upload session in `uploads` (a `{mtype
session-id}` map) into a temp file, validates the media type and
size of every entry, and returns a `{mtype path}` data map."
[cfg {:keys [uploads] :as params}]
[cfg {:keys [::rpc/profile-id uploads] :as params}]
(let [data (reduce-kv
(fn [acc mtype session-id]
(let [assembled (assemble-chunks cfg session-id)]
(let [assembled (assemble-chunks cfg profile-id session-id)]
(-> {:mtype mtype :size (:size assembled)}
(media.v/validate-media-type! cm/font-types)
(media.v/validate-font-size!))

View File

@ -272,8 +272,13 @@
(clone-file-media-object cfg params))
(defn clone-file-media-object
[{:keys [::db/conn]} {:keys [id file-id is-local]}]
[{:keys [::db/conn] :as cfg} {:keys [id file-id is-local] :as params}]
(let [mobj (db/get-by-id conn :file-media-object id)]
(when-not mobj
(ex/raise :type :not-found
:code :object-not-found
:hint "source media object not found"))
(files/check-read-permissions! conn (::rpc/profile-id params) (:file-id mobj))
(db/insert! conn :file-media-object
{:id (uuid/next)
:file-id file-id
@ -289,7 +294,7 @@
(def ^:private schema:create-upload-session
[:map {:title "create-upload-session"}
[:total-chunks ::sm/int]])
[:total-chunks [::sm/int {:min 1}]]])
(def ^:private schema:create-upload-session-result
[:map {:title "create-upload-session-result"}
@ -402,9 +407,10 @@
Raises a :validation/:missing-chunks error when the number of stored
chunks does not match `:total-chunks` recorded in the session row.
Raises :not-found when the session does not belong to `profile-id`.
Deletes the session row from `upload_session` on success."
[{:keys [::db/conn] :as cfg} session-id]
(let [session (db/get conn :upload-session {:id session-id})
[{:keys [::db/conn] :as cfg} profile-id session-id]
(let [session (db/get conn :upload-session {:id session-id :profile-id profile-id})
chunks (get-upload-chunks conn session-id)]
(when (not= (count chunks) (:total-chunks session))
@ -447,7 +453,7 @@
(db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}]
(let [content (assemble-chunks cfg session-id)
(let [content (assemble-chunks cfg profile-id session-id)
content (-> content
(assoc :filename (str "upload:" name))
(assoc :mtype mtype)

View File

@ -698,10 +698,19 @@
(if authorized
{:authorized true :reason :sso-satisfied}
(if (oidc/organization-sso-discovery-uri sso)
{:authorized false
:redirect-uri (oidc/build-organization-sso-auth-redirect-uri cfg sso
:dest-url url
:organization-id organization-id)}
(try
(let [redirect-uri (oidc/build-organization-sso-auth-redirect-uri
cfg sso
:dest-url url
:organization-id organization-id)
organization-id (or organization-id (:organization-id sso))]
(oidc/submit-organization-sso-auth-started-event
cfg request profile-id organization-id)
{:authorized false :redirect-uri redirect-uri})
(catch Throwable cause
(oidc/submit-organization-sso-auth-failed-event
cfg request profile-id (or organization-id (:organization-id sso)) cause)
(throw cause)))
{:authorized false
:redirect-uri nil}))))
{:authorized true :reason :sso-satisfied}))

View File

@ -385,6 +385,9 @@
(def ^:private test-profile-id
#uuid "11111111-1111-1111-1111-111111111111")
(def ^:private test-organization-id
#uuid "22222222-2222-2222-2222-222222222222")
(def ^:private test-profile
{:id test-profile-id
:is-active true
@ -519,6 +522,59 @@
(t/is (= 302 (::yres/status result)))
(t/is (.contains loc "error=unable-to-auth")))))))
(t/deftest organization-sso-callback-success-emits-succeeded
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace"
:organization-id test-organization-id})
request (default-request cfg :state state)
events (atom [])]
(with-redefs [app.nitrate/call (constantly {:active true})
app.auth.oidc/prepare-organization-sso-provider (constantly {:type "oidc"})
app.auth.oidc/get-info (constantly {})
app.loggers.audit/submit (fn [_cfg event] (swap! events conj event))]
(let [result (#'oidc/callback-handler cfg request)]
(t/is (= "https://penpot.example.com/#/workspace" (redirect-location result)))
(t/is (= ["organization-sso-auth-succeeded"] (mapv :name @events)))
(t/is (= test-organization-id (get-in (first @events) [:props :organization-id])))))))
(t/deftest organization-sso-callback-error-emits-failed
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace"
:organization-id test-organization-id})
request (default-request cfg :state state)
events (atom [])]
(with-redefs [app.nitrate/call (fn [_cfg method _params]
(case method
:get-organization-sso {:active true}
:get-organization-summary {:name "Organization"}))
app.auth.oidc/prepare-organization-sso-provider (constantly {:type "oidc"})
app.auth.oidc/get-info (fn [& _]
(ex/raise :type :internal
:code :unable-to-retrieve-user-info))
app.loggers.audit/submit (fn [_cfg event] (swap! events conj event))]
(#'oidc/callback-handler cfg request)
(t/is (= ["organization-sso-auth-failed"] (mapv :name @events)))
(t/is (= {:organization-id test-organization-id
:failure-reason "user-info-failed"}
(:props (first @events)))))))
(t/deftest organization-sso-oauth-error-emits-failed-without-changing-redirect
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace"
:organization-id test-organization-id})
request (assoc-in (default-request cfg :state state) [:params :error] "access_denied")
events (atom [])]
(binding [cf/config {:public-uri "http://localhost:3449"}]
(with-redefs [app.loggers.audit/submit (fn [_cfg event] (swap! events conj event))]
(let [result (#'oidc/callback-handler cfg request)
loc (redirect-location result)]
(t/is (.contains loc "error=unable-to-auth"))
(t/is (.contains loc "hint=access_denied"))
(t/is (= ["organization-sso-auth-failed"] (mapv :name @events)))
(t/is (= {:organization-id test-organization-id
:failure-reason "access-denied"}
(:props (first @events)))))))))
(t/deftest prepare-organization-sso-provider-does-not-skip-ssrf-check
(t/testing "organization SSO provider must use SSRF protection"
(let [captured-params (atom nil)]

View File

@ -11,8 +11,7 @@
[app.rpc :as-alias rpc]
[app.rpc.commands.binfile :as binfile]
[backend-tests.helpers :as th]
[clojure.test :as t]
[datoteka.fs :as fs]))
[clojure.test :as t]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
@ -32,3 +31,35 @@
(t/is (not (contains? (sm/keys (second schema)) :file-id))
"file-id should not be a declared parameter")))
(t/deftest import-binfile-schema-rejects-unsupported-version
;; T1-N2-03: version parameter should be restricted to supported values (1 or 3)
(let [schema @#'binfile/schema:import-binfile
validator (sm/lazy-validator schema)
base-params {:name "test"
:project-id (uuid/random)
:upload-id (uuid/random)}]
;; Version 1 should be accepted
(t/is (true? (validator (assoc base-params :version 1)))
"version 1 should be valid")
;; Version 3 should be accepted
(t/is (true? (validator (assoc base-params :version 3)))
"version 3 should be valid")
;; Version 2 should be rejected
(t/is (false? (validator (assoc base-params :version 2)))
"version 2 should be rejected")
;; Version 0 should be rejected
(t/is (false? (validator (assoc base-params :version 0)))
"version 0 should be rejected")
;; Negative version should be rejected
(t/is (false? (validator (assoc base-params :version -1)))
"negative version should be rejected")
;; Version 4 should be rejected
(t/is (false? (validator (assoc base-params :version 4)))
"version 4 should be rejected")))

View File

@ -2467,3 +2467,130 @@
err (:error out)]
(t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found))))
(t/deftest share-link-deletion-idor
(let [owner (th/create-profile* 1 {:is-active true})
editor (th/create-profile* 2 {:is-active true})
admin (th/create-profile* 3 {:is-active true})
proj-id (:default-project-id owner)
team-id (:default-team-id owner)
file (th/create-file* 1 {:profile-id (:id owner)
:project-id proj-id
:is-shared false})
;; Invite editor to the team with edit permissions
_ (th/create-team-role* {:team-id team-id
:profile-id (:id editor)
:role :editor})
;; Invite admin to the team with admin permissions
_ (th/create-team-role* {:team-id team-id
:profile-id (:id admin)
:role :admin})
;; Owner creates a share-link
slink (th/command! {::th/type :create-share-link
::rpc/profile-id (:id owner)
:file-id (:id file)
:pages #{(get-in file [:data :pages 0])}
:who-comment "team"
:who-inspect "all"})
slink-id (get-in slink [:result :id])]
(t/testing "owner can delete their own share-link"
(let [out (th/command! {::th/type :delete-share-link
::rpc/profile-id (:id owner)
:id slink-id})]
(t/is (nil? (:error out)))))
(t/testing "editor CANNOT delete owner's share-link (IDOR)"
;; Recreate the share-link for this test
(let [slink2 (th/command! {::th/type :create-share-link
::rpc/profile-id (:id owner)
:file-id (:id file)
:pages #{}
:who-comment "team"
:who-inspect "team"})
slink2-id (get-in slink2 [:result :id])
;; Editor tries to delete owner's share-link
out (th/command! {::th/type :delete-share-link
::rpc/profile-id (:id editor)
:id slink2-id})
err (:error out)
edata (ex-data err)]
;; Should be denied with authorization error
(t/is (th/ex-info? err))
(t/is (= :authorization (:type edata)))
;; Verify the share-link still exists
(let [check (th/command! {::th/type :get-view-only-bundle
::rpc/profile-id (:id owner)
:file-id (:id file)})
share-links (:share-links (:result check))]
(t/is (some #(= slink2-id (:id %)) share-links)))))))
(t/deftest share-link-deletion-escape-hatches
(let [owner (th/create-profile* 1 {:is-active true})
editor (th/create-profile* 2 {:is-active true})
admin (th/create-profile* 3 {:is-active true})
proj-id (:default-project-id owner)
team-id (:default-team-id owner)
file (th/create-file* 1 {:profile-id (:id owner)
:project-id proj-id
:is-shared false})
;; Invite editor to the team with edit permissions
_ (th/create-team-role* {:team-id team-id
:profile-id (:id editor)
:role :editor})
;; Invite admin to the team with admin permissions
_ (th/create-team-role* {:team-id team-id
:profile-id (:id admin)
:role :admin})]
(t/testing "editor CAN delete their own share-link"
(let [slink (th/command! {::th/type :create-share-link
::rpc/profile-id (:id editor)
:file-id (:id file)
:pages #{}
:who-comment "team"
:who-inspect "team"})
slink-id (get-in slink [:result :id])
out (th/command! {::th/type :delete-share-link
::rpc/profile-id (:id editor)
:id slink-id})]
(t/is (nil? (:error out)))))
(t/testing "admin CAN delete editor's share-link"
(let [slink (th/command! {::th/type :create-share-link
::rpc/profile-id (:id editor)
:file-id (:id file)
:pages #{}
:who-comment "team"
:who-inspect "team"})
slink-id (get-in slink [:result :id])
out (th/command! {::th/type :delete-share-link
::rpc/profile-id (:id admin)
:id slink-id})]
(t/is (nil? (:error out)))))
(t/testing "owner CAN delete editor's share-link"
(let [slink (th/command! {::th/type :create-share-link
::rpc/profile-id (:id editor)
:file-id (:id file)
:pages #{}
:who-comment "team"
:who-inspect "team"})
slink-id (get-in slink [:result :id])
out (th/command! {::th/type :delete-share-link
::rpc/profile-id (:id owner)
:id slink-id})]
(t/is (nil? (:error out)))))))

View File

@ -581,6 +581,41 @@
(t/is (some? (:error out)))
(t/is (= :not-found (-> out :error ex-data :type)))))
(t/deftest chunked-upload-other-profile-cannot-assemble
;; assemble-chunks must scope the session lookup to the requesting
;; profile so that a different profile cannot assemble chunks from
;; a session they do not own (BOLA / CWE-639).
(let [prof1 (th/create-profile* 1)
prof2 (th/create-profile* 2)
session-id (create-session! prof1 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
mfile {:filename "sample.jpg"
:path source-path
:mtype "image/jpeg"
:size 312043}]
;; prof1 uploads a chunk into their own session
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof1)
:session-id session-id
:index 0
:content mfile})]
(t/is (nil? (:error out))))
;; prof2 tries to assemble prof1's session via create-font-variant
;; (which calls assemble-chunks without ownership check)
(let [out (th/command! {::th/type :create-font-variant
::rpc/profile-id (:id prof2)
:team-id (:default-team-id prof2)
:font-id (uuid/next)
:font-family "TestFont"
:font-weight 400
:font-style "normal"
:uploads {"font/ttf" session-id}})]
(t/is (some? (:error out)))
(t/is (= :not-found (-> out :error ex-data :type)))
(t/is (= :object-not-found (-> out :error ex-data :code))))))
(t/deftest chunked-upload-invalid-media-type
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
@ -683,6 +718,24 @@
(t/is (= :max-quote-reached (-> out :error ex-data :code)))
(t/is (= "upload-chunks-per-session" (-> out :error ex-data :target))))))
(t/deftest chunked-upload-invalid-total-chunks
;; total-chunks must be at least 1; zero and negative values are rejected
;; with a :validation error.
(let [prof (th/create-profile* 1)]
;; zero total-chunks
(let [out (th/command! {::th/type :create-upload-session
::rpc/profile-id (:id prof)
:total-chunks 0})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type))))
;; negative total-chunks
(let [out (th/command! {::th/type :create-upload-session
::rpc/profile-id (:id prof)
:total-chunks -1})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type))))))
(t/deftest chunked-upload-invalid-chunk-index
;; Both a negative index and an index >= total-chunks must be
;; rejected with a :validation / :invalid-chunk-index error.
@ -734,3 +787,98 @@
(t/is (some? (:error out)))
(t/is (= :restriction (-> out :error ex-data :type)))
(t/is (= :max-quote-reached (-> out :error ex-data :code)))))))
;; --- Clone File Media Object BOLA tests ---
(defn- create-storage-object!
[content content-type]
(let [storage (:app.storage/storage th/*system*)]
(sto/put-object! storage {::sto/content (sto/content content)
:content-type content-type})))
(t/deftest clone-file-media-object-success
(let [prof1 (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof1)
:team-id (:default-team-id prof1)})
file1 (th/create-file* 1 {:profile-id (:id prof1)
:project-id (:default-project-id prof1)
:is-shared false})
sobj (create-storage-object! "image-content" "image/png")
mobj (th/create-file-media-object* {:file-id (:id file1)
:name "test-media"
:width 100
:height 100
:mtype "image/png"
:media-id (:id sobj)})
file2 (th/create-file* 2 {:profile-id (:id prof1)
:project-id (:default-project-id prof1)
:is-shared false})
params {::th/type :clone-file-media-object
::rpc/profile-id (:id prof1)
:file-id (:id file2)
:is-local true
:id (:id mobj)}
out (th/command! params)]
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= (:id file2) (:file-id result)))
(t/is (= (:name mobj) (:name result)))
(t/is (= (:media-id mobj) (:media-id result)))
(t/is (uuid? (:id result)))
(t/is (not= (:id mobj) (:id result))))))
(t/deftest clone-file-media-object-no-read-access
(let [prof1 (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof1)
:team-id (:default-team-id prof1)})
file1 (th/create-file* 1 {:profile-id (:id prof1)
:project-id (:default-project-id prof1)
:is-shared false})
sobj (create-storage-object! "private-content" "image/png")
mobj (th/create-file-media-object* {:file-id (:id file1)
:name "private-media"
:width 100
:height 100
:mtype "image/png"
:media-id (:id sobj)})
prof2 (th/create-profile* 2)
_ (th/create-project* 2 {:profile-id (:id prof2)
:team-id (:default-team-id prof2)})
file2 (th/create-file* 2 {:profile-id (:id prof2)
:project-id (:default-project-id prof2)
:is-shared false})
params {::th/type :clone-file-media-object
::rpc/profile-id (:id prof2)
:file-id (:id file2)
:is-local true
:id (:id mobj)}
out (th/command! params)]
(let [error (:error out)
error-data (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= :not-found (:type error-data)))
(t/is (= :object-not-found (:code error-data))))))
(t/deftest clone-file-media-object-source-not-found
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
params {::th/type :clone-file-media-object
::rpc/profile-id (:id prof)
:file-id (:id file)
:is-local true
:id (uuid/random)}
out (th/command! params)]
(let [error (:error out)
error-data (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= :not-found (:type error-data)))
(t/is (= :object-not-found (:code error-data))))))

View File

@ -148,6 +148,8 @@
team (th/create-team* 1 {:profile-id (:id team-owner)})
organization-id (uuid/random)
redirect-uri "https://idp.example.com/authorize"
redirect-options (atom nil)
started-event (atom nil)
params (with-meta
{::th/type :check-nitrate-sso
::rpc/profile-id (:id team-owner)
@ -161,12 +163,50 @@
organization-id
(:id team-owner))
oidc/build-organization-sso-auth-redirect-uri
(constantly redirect-uri)]
(fn [_cfg _sso & options]
(reset! redirect-options (apply hash-map options))
redirect-uri)
oidc/submit-organization-sso-auth-started-event
(fn [_cfg _request profile-id received-organization-id]
(reset! started-event {:profile-id profile-id
:organization-id received-organization-id}))]
(let [out (th/command! params)]
(t/is (th/success? out))
(t/is (= {:authorized false
:redirect-uri redirect-uri}
(:result out))))))))
(:result out)))
(t/is (= #{:dest-url :organization-id} (set (keys @redirect-options))))
(t/is (= "https://penpot.example.com/#/workspace" (str (:dest-url @redirect-options))))
(t/is (nil? (:organization-id @redirect-options)))
(t/is (= {:profile-id (:id team-owner)
:organization-id organization-id}
@started-event)))))))
(t/deftest check-nitrate-sso-reports-redirect-failure
(let [profile (th/create-profile* 1 {:is-active true})
organization-id (uuid/random)
cause (ex-info "provider unavailable" {:response-status-code 503})
reported (atom nil)
params (with-meta
{::th/type :check-nitrate-sso
::rpc/profile-id (:id profile)
:organization-id organization-id
:url "https://penpot.example.com/#/workspace"}
{::http/request {}})]
(binding [cf/flags (conj cf/flags :admin-console)]
(with-redefs [nitrate/sso-session-authorized? (unauthorized-sso-mock organization-id)
oidc/build-organization-sso-auth-redirect-uri (fn [& _] (throw cause))
oidc/submit-organization-sso-auth-failed-event
(fn [_cfg _request profile-id received-organization-id received-cause]
(reset! reported {:profile-id profile-id
:organization-id received-organization-id
:cause received-cause}))]
(let [out (th/command! params)]
(t/is (not (th/success? out)))
(t/is (= {:profile-id (:id profile)
:organization-id organization-id
:cause cause}
@reported)))))))
(t/deftest check-nitrate-sso-keeps-gate-for-non-member-organization-owner
(let [team-owner (th/create-profile* 1 {:is-active true})

View File

@ -710,6 +710,19 @@
(t/is (not (contains? (:props result) :route)))
(t/is (not (contains? (:props result) :label)))))
(t/deftest test-filter-telemetry-props-organization-sso-failure-keeps-reason
(let [ftp (ns-resolve 'app.loggers.audit 'filter-telemetry-props)
organization-id (uuid/next)
result (ftp {:source "backend"
:name "organization-sso-auth-failed"
:type "action"
:props {:organization-id organization-id
:failure-reason "access-denied"
:unsafe-label "should-be-stripped"}})]
(t/is (= {:organization-id organization-id
:failure-reason "access-denied"}
(:props result)))))
(t/deftest test-filter-telemetry-props-navigate-keeps-route-and-ids
;; Frontend navigate events keep specific routing keys: :route,
;; :file-id, :team-id, :page-id. These ids are strings because

View File

@ -1192,6 +1192,15 @@
str/trim)
""))
(defn escape-markdown
"Escapes Markdown special characters by prefixing them with backslash.
Intended for user-controlled values embedded in Markdown messages
(e.g. Mattermost notifications)."
[s]
(if s
(str/replace (str s) #"([*_~`\[\]()>#+=\-|{}.!@\\])" (fn [[_ c]] (str "\\" c)))
""))
(defn get-initials
"Returns up to two uppercase initials extracted from a string.
Non-letter prefixes in each token are ignored."

View File

@ -23,7 +23,7 @@
"image/svg+xml"})
(def tempfile-types
(conj image-types "application/pdf"))
(conj image-types "application/pdf" "application/zip"))
(defn format->extension
[format]

View File

@ -54,6 +54,25 @@
(t/is (= :keyword (d/normalize-string :keyword)))
(t/is (= true (d/normalize-string true))))
(t/deftest escape-markdown-test
(t/is (= "hello" (d/escape-markdown "hello")))
(t/is (= "" (d/escape-markdown nil)))
(t/is (= "" (d/escape-markdown "")))
(t/is (= "\\*bold\\*" (d/escape-markdown "*bold*")))
(t/is (= "\\_italic\\_" (d/escape-markdown "_italic_")))
(t/is (= "\\~strikethrough\\~" (d/escape-markdown "~strikethrough~")))
(t/is (= "\\`code\\`" (d/escape-markdown "`code`")))
(t/is (= "\\[link\\]\\(http://evil\\.com\\)" (d/escape-markdown "[link](http://evil.com)")))
(t/is (= "\\> quote" (d/escape-markdown "> quote")))
(t/is (= "\\# heading" (d/escape-markdown "# heading")))
(t/is (= "\\@channel" (d/escape-markdown "@channel")))
(t/is (= "\\!bang" (d/escape-markdown "!bang")))
(t/is (= "normal\\-text" (d/escape-markdown "normal-text")))
(t/is (= "a\\+b\\=c" (d/escape-markdown "a+b=c")))
(t/is (= "pipe\\|separated" (d/escape-markdown "pipe|separated")))
(t/is (= "curly\\{\\}braces" (d/escape-markdown "curly{}braces")))
(t/is (= "backslash\\\\slash" (d/escape-markdown "backslash\\slash"))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Ordered Data Structures
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@ -40,6 +40,7 @@
[app.main.data.workspace.groups :as dwg]
[app.main.data.workspace.notifications :as-alias dwn]
[app.main.data.workspace.pages :as-alias dwpg]
[app.main.data.workspace.reflow :as wrf]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.specialized-panel :as dwsp]
@ -1129,6 +1130,16 @@
(def valid-asset-types
#{:colors :components :typographies})
(defn- sync-file-pending-ids
[file-id changes]
;; Track the file and every changed page object.
(into #{file-id}
(comp
(filter :page-id)
(keep :id)
(remove uuid/zero?))
(:redo-changes changes)))
(defn set-updating-library
[updating?]
(ptk/reify ::set-updating-library
@ -1138,6 +1149,32 @@
(assoc state :updating-library true)
(dissoc state :updating-library)))))
(defn- sync-file-frontend-events
[file-id changes updated-frames undo-group]
(rx/concat
(rx/of (set-updating-library false)
(ntf/hide {:tag :sync-dialog}))
(when (seq (:redo-changes changes))
(rx/of (dch/commit-changes changes)))
(when-not (empty? updated-frames)
(let [frames-by-page (group-by :page-id updated-frames)]
(rx/merge
;; Emit one layout/update event for each page.
(->> frames-by-page
(map (fn [[page-id frames]]
(ptk/data-event :layout/update
{:page-id page-id
:ids (map :id frames)
:undo-group undo-group})))
(rx/from))
(->> (rx/from updated-frames)
(rx/mapcat
(fn [shape]
(rx/of
(dwt/clear-thumbnail file-id (:page-id shape) (:id shape) "frame")
(when-not (= (:frame-id shape) uuid/zero)
(dwt/clear-thumbnail file-id (:page-id shape) (:frame-id shape) "frame")))))))))))
(defn sync-file
"Synchronize the given file from the given library. Walk through all
shapes in all pages in the file that use some color, typography or
@ -1196,35 +1233,20 @@
updated-frames (->> changes
:redo-changes
(mapcat find-frames)
distinct)]
distinct)
pending-ids (sync-file-pending-ids file-id changes)
frontend-sync
(sync-file-frontend-events
file-id changes updated-frames undo-group)]
(log/debug :msg "SYNC-FILE finished" :js/rchanges (log-changes
(:redo-changes changes)
ldata))
(rx/concat
(rx/of (set-updating-library false)
(ntf/hide {:tag :sync-dialog}))
(when (seq (:redo-changes changes))
(rx/of (dch/commit-changes changes)))
(when-not (empty? updated-frames)
(let [frames-by-page (->> updated-frames
(group-by :page-id))]
(rx/merge
;; Emit one layout/update event for each page
(rx/from
(map (fn [[page-id frames]]
(ptk/data-event :layout/update
{:page-id page-id
:ids (map :id frames)
:undo-group undo-group}))
frames-by-page))
(->> (rx/from updated-frames)
(rx/mapcat
(fn [shape]
(rx/of
(dwt/clear-thumbnail file-id (:page-id shape) (:id shape) "frame")
(when-not (= (:frame-id shape) uuid/zero)
(dwt/clear-thumbnail file-id (:page-id shape) (:frame-id shape) "frame")))))))))
;; Keep the sync pending until its layout work starts.
(wrf/with-pending :sync-file pending-ids frontend-sync)
(when (not= file-id library-id)
;; When we have just updated the library file, give some time for the
@ -1400,66 +1422,88 @@
(rx/buffer 2 1)
(rx/map first))
changes-s
;; Barriers open before async inspection and close after detection.
pending-sync-barriers* (atom #{})
start-sync-barrier
(fn [{:keys [file-id save-undo?] :as event}]
(let [task (when (and save-undo? (uuid? file-id))
(wrf/start! :sync-file [file-id]))]
(when task
(swap! pending-sync-barriers* conj task))
[event task]))
finish-sync-barrier!
(fn [task]
(when task
(wrf/finish! task)
(swap! pending-sync-barriers* disj task)))
commits-s
(->> stream
(rx/filter dch/commit?)
(rx/map deref)
(rx/filter #(= :local (:source %)))
;; Translation commits never propagate component changes.
(rx/filter (complement :translation?))
;; Keep waits pending while component changes are checked.
(rx/map start-sync-barrier)
(rx/observe-on :async))
check-changes
get-component-events
(fn [[event old-data]]
(cond
(nil? old-data)
(rx/empty)
(let [{:keys [file-id changes save-undo? undo-group]} event
changed-components
(when (and old-data
(or (nil? file-id) (= file-id (:id old-data))))
(into #{}
(mapcat (partial ch/components-changed old-data))
changes))]
(cond
(empty? changed-components)
(rx/empty)
(:translation? event)
(rx/empty)
save-undo?
(do
(log/info :hint "detected component changes"
:ids (map str changed-components)
:undo-group undo-group)
(->> (rx/from changed-components)
(rx/map #(component-changed
% (:id old-data) undo-group))))
:else
(let [{:keys [file-id changes save-undo? undo-group]} event
:else
;; Undos only bump :modified-at.
(->> (rx/from changed-components)
(rx/map touch-component)))))
changed-components
(when (or (nil? file-id) (= file-id (:id old-data)))
(->> changes
(map (partial ch/components-changed old-data))
(reduce into #{})))]
(if (d/not-empty? changed-components)
(if save-undo?
(do (log/info :hint "detected component changes"
:ids (map str changed-components)
:undo-group undo-group)
(->> (rx/from changed-components)
(rx/map #(component-changed % (:id old-data) undo-group))))
;; save-undo? false (undos): just bump :modified-at
(->> (rx/from changed-components)
(rx/map touch-component)))
(rx/empty)))))
changes-s
(->> changes-s
component-events-s
(->> commits-s
(rx/with-latest-from workspace-buffer-s)
(rx/mapcat check-changes)
(rx/mapcat
(fn [[[event task] old-data]]
(->> (get-component-events [event old-data])
(rx/finalize #(finish-sync-barrier! task)))))
;; Close barriers left behind when the page shuts down.
(rx/finalize #(wrf/finish-tasks! @pending-sync-barriers*))
(rx/share))
notifier-s
(->> changes-s
(->> component-events-s
(rx/debounce 5000)
(rx/tap #(log/trc :hint "buffer initialized")))]
(when (or (contains? cf/flags :component-thumbnails)
(features/active-feature? state "render-wasm/v1"))
(->> (rx/merge
changes-s
component-events-s
;; WASM only: render the thumbnail on every component
;; change so single edits (fill, etc.) update instantly.
;; Non-WASM persists on every render, so it stays on the
;; debounced path below to avoid per-edit backend posts.
(if (features/active-feature? state "render-wasm/v1")
(->> changes-s
(->> component-events-s
(rx/filter (ptk/type? ::component-changed))
(rx/map deref)
(rx/map render-component-thumbnail-event))
@ -1467,7 +1511,7 @@
;; Persist to the server in batches, 5s after the user
;; goes idle.
(->> changes-s
(->> component-events-s
(rx/filter (ptk/type? ::component-changed))
(rx/map deref)
(rx/buffer-until notifier-s)
@ -1476,7 +1520,7 @@
(update-component-thumbnail component-id file-id))))
;; Undo/redo emit touch-component instead.
(->> changes-s
(->> component-events-s
(rx/filter (ptk/type? ::touch-component))
(rx/map deref)
(rx/map render-component-thumbnail-event)))
@ -1631,5 +1675,3 @@
(rx/mapcat (fn [_]
(rp/cmd! :get-file-libraries {:file-id file-id})))
(rx/map (partial cleanup-unlinked-libraries file-id))))))

View File

@ -14,6 +14,7 @@
[app.main.broadcast :as mbc]
[app.main.data.plugins :as dp]
[app.main.data.profile :as du]
[app.main.data.workspace :as-alias dw]
[app.main.store :as st]
[app.plugins.register :as preg]
[app.util.timers :as ts]
@ -132,7 +133,7 @@
(assoc :host (str (u/join cf/public-uri "plugins/mcp/"))))
stopper-s (rx/merge
(rx/filter (ptk/type? :app.main.data.workspace/finalize-workspace) stream)
(rx/filter (ptk/type? ::dw/finalize-workspace) stream)
(rx/filter (ptk/type? ::stop-mcp-plugin) stream))
extension #js {:getToken (constantly token)
@ -202,7 +203,7 @@
ptk/WatchEvent
(watch [_ state stream]
(let [stopper-s (rx/merge
(rx/filter (ptk/type? :app.main.data.workspace/finalize-workspace) stream)
(rx/filter (ptk/type? ::dw/finalize-workspace) stream)
(rx/filter (ptk/type? ::init) stream))
session-id (get state :session-id)

View File

@ -5,11 +5,13 @@
;; Copyright (c) KALEIDOS INC
(ns app.main.data.workspace.reflow
"Tracks the shape ids that have layout/reflow work in flight, broken down by
the kind of work so we can tell which type of reflow is blocking each shape.
"Tracks the ids that have layout/reflow work in flight, broken down by the
kind of work so we can tell which type of reflow is blocking each id.
Pending work is stored as `{shape-id -> {kind -> #{task-id}}}`. Every producer
opens an exact task with `start!` and closes that same task with `finish!`.
Pending work is stored as `{id -> {kind -> #{task-id}}}`, where ids are page
object ids plus, for `:sync-file`, the id of the file being synced. Every
producer opens an exact task with `start!` and closes that same task with
`finish!`.
Tasks belong to a workspace generation, so a delayed completion from a
finalized workspace cannot drain work opened after the workspace reloads.
@ -21,8 +23,10 @@
:layout flex/grid layout reflow (shape-layout)
:text-resize text geometry resize (wasm-text, texts)
:text-measure DOM text measurement (texts)
:text-position DOM text fragment geometry (texts)
:text-bridge change awaiting its pipeline (texts)
:font font change measurement (texts)"
:font font change measurement (texts)
:sync-file component/library propagation (libraries)"
(:require
[beicon.v2.core :as rx]
[promesa.core :as p]))
@ -55,22 +59,35 @@
acc
ids)))
;; Single-task operations are wrapped as batches before reaching the reducer.
(defn- reducer
[acc {:keys [op task ids]}]
[acc {:keys [op tasks ids]}]
(case op
:add (add-task acc task)
:remove (remove-task acc task)
:add (reduce add-task acc tasks)
:remove (reduce remove-task acc tasks)
:cancel (apply dissoc acc ids)
:reset {}
acc))
;; Behaviour subject holding `{shape-id -> {kind -> #{task-id}}}`.
;; It replays its current value synchronously to new subscribers, which gives
;; `wait-for-layout-update` a free fast-path when there is nothing pending.
(defonce ^:private pending-shapes
(let [sub (rx/behavior-subject {})]
(rx/sub! (->> reflow-input (rx/scan reducer {})) sub)
sub))
;; Holds pending tasks and replays them to new waiters.
;; Reloads rebuild the scan with the latest reducer.
(def ^:private pending-shapes (rx/behavior-subject {}))
(defonce ^:private pending-subscription (atom nil))
(defn- install-pending-subscription!
[]
;; Settle the old scan before installing the new one.
(swap! workspace-generation inc)
(rx/push! reflow-input {:op :reset})
(when-let [subscription @pending-subscription]
(rx/dispose! subscription))
(reset! pending-subscription
(rx/sub! (->> reflow-input (rx/scan reducer {}))
pending-shapes))
(rx/push! reflow-input {:op :reset}))
(install-pending-subscription!)
(defn task
"Creates an opaque task token without opening it."
@ -80,24 +97,42 @@
:kind kind
:ids (into #{} ids)})
(defn- push-tasks!
[op tasks]
;; Empty and stale tasks must not affect the active workspace.
(let [generation @workspace-generation
tasks (into [] (filter #(and (seq (:ids %))
(= (:generation %) generation)))
tasks)]
(when (seq tasks)
(rx/push! reflow-input {:op op :tasks tasks}))
tasks))
(defn- start-tasks!
"Opens task tokens in one pending-map update."
[tasks]
(push-tasks! :add tasks))
(defn start!
"Opens and returns a task. The one-argument form opens a token created with
`task`; the two-argument form creates and opens it in one step."
([task]
(when (and (seq (:ids task))
(= (:generation task) @workspace-generation))
(rx/push! reflow-input {:op :add :task task}))
(push-tasks! :add [task])
task)
([kind ids]
(start! (task kind ids))))
(defn finish-tasks!
"Closes task tokens from the active workspace generation in one update."
[tasks]
(push-tasks! :remove tasks)
nil)
(defn finish!
"Closes `task` if it belongs to the active workspace generation. Repeated or
stale completion is a no-op."
[{:keys [generation ids] :as task}]
(when (and (seq ids)
(= generation @workspace-generation))
(rx/push! reflow-input {:op :remove :task task})))
[task]
(finish-tasks! [task]))
(defn reset-pending!
"Starts a new workspace generation and forgets every task from the old one."
@ -136,59 +171,77 @@
(finish! task)
(throw cause)))))
(defn pending-signal
"Emits once any of `kinds` is pending for any of `ids`, then completes.
Emits right away when that work is already in flight."
[ids kinds]
(letfn [(id-pending? [pending id]
(some (partial contains? (get pending id)) kinds))
(defn bridge-pending
"Keeps each id pending until matching work starts."
[ids target-kinds bridge-kind]
(let [ids (into #{} ids)]
(if (empty? ids)
(rx/empty)
(rx/create
(fn [subs]
;; Separate tasks let renderer work release each shape independently.
(let [tasks-by-id
(into {} (map (fn [id] [id (task bridge-kind [id])])) ids)
(any-pending? [pending]
(some (partial id-pending? pending) ids))]
(->> pending-shapes
(rx/filter any-pending?)
(rx/take 1))))
remaining
(atom ids)
;; Ceiling for callers that pass no timeout, so a pipeline that never drains
;; its marks rejects the promise rather than leaving it unsettled.
(def ^:private default-timeout 30000)
release!
(fn [released]
(let [released (into #{} (filter @remaining) released)]
(when (seq released)
(finish-tasks! (map tasks-by-id released))
(swap! remaining #(apply disj % released))
(when (empty? @remaining)
(rx/end! subs)))))
(defn wait-for-layout-update
"Returns a JS Promise that resolves when every id in `shape-ids` has drained
from the pending map. A nil `shape-ids` waits for every pending shape; an
empty one has nothing to wait for and resolves right away. The promise is
rejected when `timeout` (ms) elapses first; a nil `timeout` uses
`default-timeout`.
matching-task-ids
(fn [tasks]
(into #{}
(comp
(filter #(contains? target-kinds (:kind %)))
(mapcat :ids)
(filter ids))
tasks))
;; Listen before opening bridges so synchronous work is not missed.
lifecycle-sub
(rx/sub!
reflow-input
(fn [{:keys [op tasks ids]}]
(case op
:add
(release! (matching-task-ids tasks))
:cancel
(release! ids)
:reset
(release! @remaining)
nil)))
_
(start-tasks! (vals tasks-by-id))]
(fn []
(rx/dispose! lifecycle-sub)
(when (seq @remaining)
(finish-tasks! (map tasks-by-id @remaining))
(reset! remaining #{})))))))))
(defn settled
"Observable that emits once every id in `ids` has drained from the pending
map, then completes. A nil `ids` waits for every pending id; an empty one has
nothing to wait for. Replays on subscribe, so an already drained map emits
immediately.
Callers waiting on one shape pass its whole subtree: reflow work lands either
on the shape (a board laying out its children) or on its descendants (a group
whose texts are re-measured)."
([timeout]
(wait-for-layout-update nil timeout))
([shape-ids timeout]
(js/Promise.
(fn [resolve reject]
(let [timeout (or timeout default-timeout)
done? (if (some? shape-ids)
(fn [pending] (not-any? #(contains? pending %) shape-ids))
empty?)
settled (->> pending-shapes
(rx/filter done?)
(rx/map (constantly :ok)))
;; Race the settle signal against the deadline; the loser is
;; unsubscribed. `settled` replays on subscribe, so an already
;; drained map wins even against a 1ms deadline.
source (rx/race (->> (rx/of :timeout)
(rx/delay timeout))
settled)]
(->> source
(rx/take 1)
(rx/subs!
(fn [value]
(if (= value :timeout)
(reject (js/Error. "waitForLayoutUpdate timeout"))
(resolve)))
reject)))))))
[ids]
(let [done? (if (some? ids)
(fn [pending] (not-any? #(contains? pending %) ids))
empty?)]
(->> pending-shapes
(rx/filter done?)
(rx/take 1))))

View File

@ -0,0 +1,141 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.main.data.workspace.reflow.signals
"Decides which reflow signals a shape update raises: `:layout/update` for the
shapes whose layout attrs changed, `:text/reflow` for the texts the renderer
has to re-measure.
Which text attrs matter depends on the renderer: the DOM one measures every
changed text, so its own geometry counts as a change; wasm only resizes
auto-sized texts from their content."
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.changes-builder :as pcb]
[app.common.files.helpers :as cfh]
[app.common.math :as mth]
[app.main.features :as features]))
;; If anything a translation can mutate is added here, drop the
;; `(when-not translation? …)` guard in `update-shapes`.
(def ^:private update-layout-attr? #{:hidden})
;; Text attrs that can start async renderer work.
(def ^:private text-reflow-attr?
#{:content :grow-type :x :y :width :height})
(def ^:private wasm-text-reflow-attr?
#{:content :grow-type})
(def ^:private dom-text-geometry-reflow-attr?
#{:x :y :width :height})
(defn- renderer-text-reflow-attr?
[state]
(if (features/active-feature? state "render-wasm/v1")
wasm-text-reflow-attr?
text-reflow-attr?))
(defn- reflow-attr?
[state attr]
(or (update-layout-attr? attr)
((renderer-text-reflow-attr? state) attr)))
;; Caller metadata can rule out reflow before objects are compared.
(defn- reflow-candidate?
[attr? {:keys [attrs translation? update-layout?]
:or {update-layout? true}}]
(and update-layout?
(not translation?)
(or (nil? attrs)
(some attr? attrs))))
(defn- text-reflow-changed?
[state shape changed-shape changed]
;; Match the DOM renderer's geometry checks.
(let [wasm? (features/active-feature? state "render-wasm/v1")
reflow-attr? (renderer-text-reflow-attr? state)]
(some
(fn [attr]
(and (reflow-attr? attr)
(or wasm?
(not (dom-text-geometry-reflow-attr? attr))
(not (mth/close? (get shape attr)
(get changed-shape attr))))))
changed)))
(defn- async-text-reflow?
"Whether `shape` enters an asynchronous text geometry pipeline. The HTML
renderer measures every changed text; WASM only resizes auto-sized texts.
A grow-type transition is included because `shape` is the value before the
update and may still be fixed."
[state shape changed]
(and (cfh/text-shape? shape)
(or (not (features/active-feature? state "render-wasm/v1"))
(not= :fixed (:grow-type shape))
(contains? changed :grow-type))))
(defn- get-reflow-changes
[state objects changed-objects ids {:keys [attrs] :as props}]
;; Reuse built objects so update functions only run once.
(let [reflow-attr? (partial reflow-attr? state)]
(when (reflow-candidate? reflow-attr? props)
(into []
(comp
(map (d/getf objects))
(keep (fn [shape]
(let [changed-shape (get changed-objects (:id shape))
changed (pcb/changed-attrs
shape objects (constantly changed-shape)
{:attrs attrs})]
(when (some reflow-attr? changed)
[shape changed-shape changed])))))
ids))))
(defn- get-layout-reflow-ids
[reflow-changes]
(->> reflow-changes
(into [] (comp (filter (fn [[_ _ changed]] (some update-layout-attr? changed)))
(map (comp :id first))))
(not-empty)))
(defn- get-text-reflow-ids
[state page-id reflow-changes]
;; Track measurable texts on the active page.
(when (= page-id (get state :current-page-id))
(let [edition (dm/get-in state [:workspace-local :edition])]
(->> reflow-changes
(into [] (comp (filter (fn [[shape changed-shape changed]]
(and (async-text-reflow? state shape changed)
(text-reflow-changed?
state shape changed-shape changed))))
(map (comp :id first))
(remove #(= % edition))))
(not-empty)))))
(defn reflow-ids
"Ids a shape update has to signal: `:layout-ids` for `:layout/update`,
`:text-ids` for `:text/reflow`. Both are nil when nothing changed.
Both sets come from one comparison pass, so `update-fn` and the attribute
diff only run once per shape."
[state page-id objects changed-objects ids props]
(let [reflow-changes (get-reflow-changes state objects changed-objects ids props)]
{:layout-ids (get-layout-reflow-ids reflow-changes)
:text-ids (get-text-reflow-ids state page-id reflow-changes)}))
(defn text-reflow-candidate?
"Whether `props` can start renderer text work, judged from the caller metadata
alone. Cheap pre-filter for callers that buffer updates before they have
objects to compare."
[state props]
(reflow-candidate? (renderer-text-reflow-attr? state) props))
(defn new-text-reflow?
"Whether a newly added `shape` enters an asynchronous text geometry pipeline."
[state shape]
(async-text-reflow? state shape nil))

View File

@ -29,6 +29,7 @@
[app.main.data.workspace.undo :as dwu]
[app.main.data.workspace.viewport-wasm :as dwvw]
[app.main.data.workspace.zoom :as dwz]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.router :as rt]
[app.main.streams :as ms]
@ -452,6 +453,16 @@
(gpt/subtract new-pos pt-obj)))))
(defn- get-new-dom-text-ids
[state changes]
(when-not (features/active-feature? state "render-wasm/v1")
(->> (:redo-changes changes)
(keep (fn [{:keys [type obj]}]
(when (and (= type :add-obj)
(cfh/text-shape? obj))
(:id obj))))
(not-empty))))
(defn duplicate-shapes
[ids & {:keys [move-delta? alt-duplication? change-selection? return-ref]
:or {move-delta? false alt-duplication? false change-selection? true return-ref nil}}]
@ -493,6 +504,9 @@
(map #(get-in % [:obj :id]))
(into (d/ordered-set)))
new-dom-text-ids
(get-new-dom-text-ids state changes)
id-duplicated (first new-ids)
frames (into #{}
@ -531,6 +545,11 @@
;; Warning: This order is important for the focus mode.
(->> (rx/of
(dwu/start-undo-transaction undo-id)
;; Track cloned texts before they mount.
(when new-dom-text-ids
(ptk/data-event :text/reflow
{:ids new-dom-text-ids
:page-id (:id page)}))
(dch/commit-changes changes)
(when change-selection?
(select-shapes new-ids))

View File

@ -23,6 +23,7 @@
[app.main.data.changes :as dch]
[app.main.data.event :as ev]
[app.main.data.helpers :as dsh]
[app.main.data.workspace :as-alias dw]
[app.main.data.workspace.colors :as cl]
[app.main.data.workspace.grid-layout.editor :as dwge]
[app.main.data.workspace.modifiers :as dwm]
@ -131,14 +132,14 @@
(->> stream
(rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit))
(rx/take 1)
(rx/take-until (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream))
(rx/take-until (rx/filter (ptk/type? ::dw/finalize-workspace) stream))
;; No events are derived from this
(rx/ignore))
(rx/empty))]
(cond->> (rx/concat update-positions-stream drain-stream)
(d/not-empty? reflow-tasks)
(rx/finalize #(run! wrf/finish! reflow-tasks)))))))
(rx/finalize #(wrf/finish-tasks! reflow-tasks)))))))
(defn- without-root-board
[ids]

View File

@ -24,34 +24,12 @@
[app.main.data.workspace.collapse :as dwco]
[app.main.data.workspace.edition :as dwe]
[app.main.data.workspace.reflow :as wrf]
[app.main.data.workspace.reflow.signals :as wrfs]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.undo :as dwu]
[app.main.features :as features]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
;; If anything a translation can mutate is added here, drop the
;; `(when-not translation? …)` guard in `update-shapes` below.
(def ^:private update-layout-attr? #{:hidden})
;; Text attrs whose change makes the DOM text pipeline re-measure the shape.
(def ^:private text-reflow-attr? #{:content :grow-type})
(defn- reflow-attr?
[attr]
(or (update-layout-attr? attr) (text-reflow-attr? attr)))
(defn- async-text-reflow?
"Whether `shape` enters an asynchronous text geometry pipeline. The HTML
renderer measures every changed text; WASM only resizes auto-sized texts.
A grow-type transition is included because `shape` is the value before the
update and may still be fixed."
[state shape changed]
(and (cfh/text-shape? shape)
(or (not (features/active-feature? state "render-wasm/v1"))
(not= :fixed (:grow-type shape))
(contains? changed :grow-type))))
(defn- add-undo-group
[changes state]
(let [undo (:workspace-undo state)
@ -82,15 +60,35 @@
(update [_ state]
(assoc state ::update-shapes-buffer false))))
(defn- get-buffered-text-reflow-event
[state page-id ids]
(when (= page-id (get state :current-page-id))
;; Analyze accumulated objects through the same path as immediate updates.
(let [objects (dsh/lookup-page-objects state page-id)
changed-objects (-> (get-in state [::update-shapes-buffer-changes page-id])
(pcb/lookup-objects))
{:keys [text-ids]}
(wrfs/reflow-ids state page-id objects changed-objects ids nil)]
(when text-ids
(ptk/data-event :text/reflow {:ids text-ids :page-id page-id})))))
(defn update-shapes-buffer-commit
[]
(ptk/reify ::update-shapes-buffer-commit
ptk/WatchEvent
(watch [_ state _]
(->> (get state ::update-shapes-buffer-changes)
(vals)
(map dch/commit-changes)
(rx/from)))))
(let [text-reflow-events
(->> (get state ::update-shapes-buffer-text-candidates)
(keep (fn [[page-id ids]]
(get-buffered-text-reflow-event state page-id ids))))
commits
(->> (get state ::update-shapes-buffer-changes)
(vals)
(map dch/commit-changes))]
;; Open bridges before commits start rendering.
(rx/concat (rx/from text-reflow-events)
(rx/from commits))))))
;; Looks for the objects data in the state, if there is an "in progress"
;; update-shapes-buffer will return the objeccts inside the current changes
@ -111,7 +109,8 @@
(update-shapes-buffer ids update-fn nil))
([ids update-fn
{:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id
ignore-touched undo-group with-objects? changed-sub-attr translation?]
ignore-touched undo-group with-objects? changed-sub-attr
translation?]
:or {reg-objects? false
save-undo? true
stack-undo? false
@ -126,9 +125,14 @@
(assoc state ::update-shapes-buffer-event cur-event)
(let [page-id (or page-id (get state :current-page-id))
objects (dsh/lookup-page-objects state page-id)]
(-> state
objects (lookup-changed-objects state page-id)
text-ids
(into #{}
(filter #(cfh/text-shape? objects %))
ids)
state
(update-in
state
[::update-shapes-buffer-changes page-id]
(fn [changes]
(-> (or changes
@ -148,7 +152,15 @@
:ignore-touched ignore-touched
:with-objects? with-objects?})
(cond-> reg-objects? (pcb/resize-parents ids))
(pcb/set-translation? translation?))))))))
(pcb/set-translation? translation?))))]
;; Check buffered text candidates when the buffer is committed.
(if (or (empty? text-ids)
(not (wrfs/text-reflow-candidate? state props)))
state
(update-in state
[::update-shapes-buffer-text-candidates page-id]
(fnil into #{})
text-ids)))))
ptk/WatchEvent
(watch [_ state stream]
@ -165,6 +177,7 @@
(rx/of #(dissoc %
::update-shapes-buffer-changes
::update-shapes-buffer-text-candidates
::update-shapes-buffer-event))))
(rx/empty)))))))
@ -174,14 +187,12 @@
([ids update-fn
{:as props
:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id
ignore-touched undo-group with-objects? changed-sub-attr translation?
update-layout?]
ignore-touched undo-group with-objects? changed-sub-attr translation?]
:or {reg-objects? false
save-undo? true
stack-undo? false
ignore-touched false
with-objects? false
update-layout? true}}]
with-objects? false}}]
(assert (every? uuid? ids) "expect a coll of uuid for `ids`")
(assert (fn? update-fn) "the `update-fn` should be a valid function")
@ -197,49 +208,6 @@
objects (dsh/lookup-page-objects state page-id)
ids (into [] (filter some?) ids)
;; Pairs of [shape changed-attrs] for the shapes whose change
;; matters to a reflow, feeding both id sets below.
xf-reflow
(comp
(map (d/getf objects))
(keep (fn [shape]
(let [changed (pcb/changed-attrs shape objects update-fn
{:attrs attrs :with-objects? with-objects?})]
(when (some reflow-attr? changed)
[shape changed])))))
;; `changed-attrs` runs `update-fn` in full for every shape, which
;; can be expensive (e.g. `update-bool-shape` recalculates the whole
;; boolean path in WASM). Skip the pass entirely when we can prove it
;; cannot match: when the caller declares `attrs`, `changed-attrs`
;; filters its result to that set, so if no reflow attr is present
;; the check is always empty.
reflow-changes
(when-not (or translation?
(not update-layout?)
(and (some? attrs)
(not (some reflow-attr? attrs))))
(into [] xf-reflow ids))
update-layout-ids
(->> reflow-changes
(into [] (comp (filter (fn [[_ changed]] (some update-layout-attr? changed)))
(map (comp :id first))))
(not-empty))
;; Text shapes the DOM pipeline has to re-measure, narrowed to what
;; it actually measures: the active page, never the edited shape.
text-reflow-ids
(when (= page-id (get state :current-page-id))
(let [edition (dm/get-in state [:workspace-local :edition])]
(->> reflow-changes
(into [] (comp (filter (fn [[shape changed]]
(and (async-text-reflow? state shape changed)
(some text-reflow-attr? changed))))
(map (comp :id first))
(remove #(= % edition))))
(not-empty))))
changes
(-> (pcb/empty-changes it page-id)
(pcb/set-save-undo? save-undo?)
@ -257,6 +225,12 @@
(pcb/set-undo-group undo-group))
(pcb/set-translation? translation?))
changed-objects
(pcb/lookup-objects changes)
{:keys [layout-ids text-ids]}
(wrfs/reflow-ids state page-id objects changed-objects ids props)
changes
(add-undo-group changes state)]
@ -264,8 +238,8 @@
;; Announces the texts still to be re-measured, so a reflow wait
;; covers the render that measures them. Goes before the commit,
;; which is what triggers that render.
(if text-reflow-ids
(rx/of (ptk/data-event :text/reflow {:ids text-reflow-ids :page-id page-id}))
(if text-ids
(rx/of (ptk/data-event :text/reflow {:ids text-ids :page-id page-id}))
(rx/empty))
(if (seq (:redo-changes changes))
@ -274,8 +248,8 @@
(rx/empty))
;; Update layouts for properties marked
(if update-layout-ids
(rx/of (ptk/data-event :layout/update {:ids update-layout-ids}))
(if layout-ids
(rx/of (ptk/data-event :layout/update {:ids layout-ids}))
(rx/empty)))))))))
(defn add-shape
@ -321,7 +295,7 @@
(rx/of (dwu/start-undo-transaction undo-id)
;; A new text has no geometry until the pipeline measures it,
;; so it raises the same signal an edit does.
(when (async-text-reflow? state shape nil)
(when (wrfs/new-text-reflow? state shape)
(ptk/data-event :text/reflow {:ids [(:id shape)] :page-id page-id}))
(dch/commit-changes changes)
(when-not no-update-layout?

View File

@ -24,9 +24,11 @@
[app.main.data.changes :as dch]
[app.main.data.event :as ev]
[app.main.data.helpers :as dsh]
[app.main.data.workspace :as-alias dw]
[app.main.data.workspace.common :as dwc]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.pages :as-alias dwpg]
[app.main.data.workspace.reflow :as wrf]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.shapes :as dwsh]
@ -67,15 +69,19 @@
"Marks `ids` pending until the text pipeline marks its own work:
`:text-measure` in the DOM renderer, `:text-resize` in wasm. Emits nothing."
[ids]
(->> (rx/from ids)
;; Each id owns its bridge. Starting work for one text must not release
;; siblings that the renderer has not picked up yet.
(rx/mapcat
(fn [id]
(->> (wrf/pending-signal [id] #{:text-measure :text-resize})
(rx/ignore)
(wrf/with-pending :text-bridge [id]))))
(rx/ignore)))
(wrf/bridge-pending ids #{:text-measure :text-resize} :text-bridge))
(defn- page-finalize?
[event]
(= ::dwpg/finalize-page (ptk/type event)))
(defn- text-work-stopper
[stream]
(rx/filter
(fn [event]
(or (= ::dw/finalize-workspace (ptk/type event))
(page-finalize? event)))
stream))
(defn initialize-text-reflow
"Tracks the texts the DOM pipeline still has to re-measure, so a reflow wait
@ -84,11 +90,15 @@
(ptk/reify ::initialize-text-reflow
ptk/WatchEvent
(watch [_ _ stream]
(let [stopper (rx/filter (ptk/type? ::finalize-text-reflow) stream)]
(let [stopper (rx/filter (ptk/type? ::finalize-text-reflow) stream)
page-stopper (rx/filter page-finalize? stream)]
(->> stream
(rx/filter (ptk/type? :text/reflow))
(rx/map deref)
(rx/mapcat (fn [{:keys [ids]}] (bridge-to-measurement ids)))
(rx/merge-map
(fn [{:keys [ids]}]
(->> (bridge-to-measurement ids)
(rx/take-until page-stopper))))
(rx/take-until stopper))))))
(defn finalize-text-reflow
@ -111,28 +121,51 @@
:else
[])))
(defn- await-font-faces
"Waits for missing WASM faces, then resizes the affected texts."
[stream face-keys ids]
(let [resize-stream (->> (rx/from ids) (rx/map dwwt/resize-wasm-text))]
(if (empty? face-keys)
resize-stream
(->> (rx/merge wasm.fonts/font-stored-stream
wasm.fonts/font-storage-failed-stream)
(rx/filter face-keys)
(rx/scan disj face-keys)
(rx/filter empty?)
(rx/take 1)
(rx/take-until (text-work-stopper stream))
(rx/observe-on :async)
(rx/mapcat (constantly resize-stream))
(wrf/with-pending :font ids)))))
(defn- pending-font-faces
[ids]
(let [objects (dsh/lookup-page-objects @st/state)]
(into #{}
(comp
(map #(get objects %))
(keep :content)
(mapcat wasm.fonts/get-content-fonts)
(map wasm.fonts/make-font-data)
(remove wasm.fonts/font-ready?)
(map wasm.fonts/font-data-key))
ids)))
(defn- await-font-resize
"Marks `ids` as pending font work and dispatches their wasm resize once wasm
can measure with `font-id`, draining the marks afterwards. The fetch of that
font is started by the wasm shape sync of the content change these shapes
receive, so measuring before it lands would use the fallback font."
[stream font-id ids]
"Waits for missing font faces, then resizes `ids`."
[stream ids]
(if (empty? ids)
(rx/empty)
(let [stopper (rx/filter (ptk/type? :app.main.data.workspace/finalize) stream)]
(->> wasm.fonts/font-stored-stream
(rx/filter #(= % font-id))
(rx/take 1)
(rx/take-until stopper)
(rx/observe-on :async)
(rx/mapcat (fn [_] (rx/from (mapv dwwt/resize-wasm-text ids))))
(wrf/with-pending :font ids)))))
(->> (rx/of ::await-fonts)
(rx/mapcat
(fn [_]
(await-font-faces stream (pending-font-faces ids) ids))))))
(defn- await-html-font
"Keeps legacy DOM text pending while its new font is loading. The DOM
measurement also awaits this promise, so the font task bridges the state
update to the renderer commit without relying on a fixed settle delay."
[font-id font-variant-id ids]
[stream font-id font-variant-id ids]
(if (or (nil? font-id) (empty? ids))
(rx/empty)
(->> (rx/of ::load-font)
@ -141,6 +174,7 @@
;; gap before the task is visible to waiters.
(rx/mapcat (fn [_]
(rx/from (fonts/ensure-loaded! font-id font-variant-id))))
(rx/take-until (text-work-stopper stream))
(rx/ignore)
(wrf/with-pending :font ids))))
@ -526,7 +560,7 @@
[id start end attrs]
(ptk/reify ::update-text-range
ptk/WatchEvent
(watch [_ state _]
(watch [_ state stream]
(let [objects (dsh/lookup-page-objects state)
shape (get objects id)
@ -548,7 +582,7 @@
(rx/map dwwt/resize-wasm-text-debounce))
(contains? attrs :font-id)
(await-html-font (:font-id attrs) (:font-variant-id attrs) text-ids)
(await-html-font stream (:font-id attrs) (:font-variant-id attrs) text-ids)
:else
(rx/empty)))))))
@ -805,7 +839,7 @@
(watch [_ state stream]
(wrf/start! reflow-task)
(if (= (::resize-text-debounce-event state) cur-event)
(let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))]
(let [stopper (->> stream (rx/filter (ptk/type? ::dw/finalize-workspace)))]
(rx/concat
(rx/merge
(->> stream
@ -816,7 +850,7 @@
(rx/take-until stopper))
(rx/of (resize-text id new-width new-height)))
(rx/of (fn [state]
(run! wrf/finish! (::resize-text-reflow-tasks state))
(wrf/finish-tasks! (::resize-text-reflow-tasks state))
(dissoc state
::resize-text-debounce-props
::resize-text-reflow-tasks
@ -885,7 +919,7 @@
ptk/WatchEvent
(watch [_ state stream]
(if (= (::update-text-modifier-debounce-event state) cur-event)
(let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))]
(let [stopper (->> stream (rx/filter (ptk/type? ::dw/finalize-workspace)))]
(rx/concat
(rx/merge
(->> stream
@ -933,40 +967,49 @@
ptk/WatchEvent
(watch [_ state _]
(let [position-data (::update-position-data state)]
(rx/concat
(rx/of (dwsh/update-shapes
(keys position-data)
(fn [shape]
(-> shape
(assoc :position-data (get position-data (:id shape)))))
{:stack-undo? true :reg-objects? false}))
(rx/of (fn [state]
(dissoc state ::update-position-data-debounce ::update-position-data))))))))
(rx/of (dwsh/update-shapes
(keys position-data)
(fn [shape]
(-> shape
(assoc :position-data (get position-data (:id shape)))))
{:stack-undo? true :reg-objects? false}))))))
(defn update-position-data
[id position-data]
(let [cur-event (js/Symbol)]
(let [cur-event (js/Symbol)
reflow-task (wrf/task :text-position [id])]
(ptk/reify ::update-position-data
ptk/UpdateEvent
(update [_ state]
(let [state (assoc-in state [:workspace-text-modifier id :position-data] position-data)]
(if (nil? (::update-position-data-debounce state))
(assoc state ::update-position-data-debounce cur-event)
(assoc-in state [::update-position-data id] position-data))))
(-> state
(update ::update-position-data-reflow-tasks (fnil conj []) reflow-task)
(cond-> (nil? (::update-position-data-debounce state))
(assoc ::update-position-data-debounce cur-event))
(cond-> (some? (::update-position-data-debounce state))
(assoc-in [::update-position-data id] position-data)))))
ptk/WatchEvent
(watch [_ state stream]
(wrf/start! reflow-task)
(if (= (::update-position-data-debounce state) cur-event)
(let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))]
(rx/merge
(->> stream
(rx/filter (ptk/type? ::update-position-data))
(rx/debounce 50)
(rx/take 1)
(rx/map #(commit-position-data))
(rx/take-until stopper))
(rx/of (update-position-data id position-data))))
(let [stopper (text-work-stopper stream)]
(rx/concat
(rx/merge
(->> stream
(rx/filter (ptk/type? ::update-position-data))
(rx/debounce 50)
(rx/take 1)
(rx/map #(commit-position-data))
(rx/take-until stopper))
(rx/of (update-position-data id position-data)))
(rx/of (fn [state]
(wrf/finish-tasks! (::update-position-data-reflow-tasks state))
(dissoc state
::update-position-data-debounce
::update-position-data
::update-position-data-reflow-tasks)))))
(rx/empty))))))
(defn update-attrs
@ -1049,7 +1092,7 @@
(let [auto-ids (into [] (remove #(= :fixed (:grow-type (get objects %)))) text-ids)]
(if (contains? attrs :font-id)
;; The geometry depends on the font, so wait until wasm has it.
(await-font-resize stream (:font-id attrs) auto-ids)
(await-font-resize stream auto-ids)
;; No font change: measurable right away.
(->> (rx/from auto-ids)
(rx/map dwwt/resize-wasm-text)))))
@ -1058,6 +1101,7 @@
;; but font loading starts before that render commits.
(if (contains? attrs :font-id)
(await-html-font
stream
(:font-id attrs)
(:font-variant-id attrs)
text-ids)

View File

@ -6,6 +6,7 @@
(ns app.main.data.workspace.tokens.errors
(:require
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[cuerdas.core :as str]))
@ -25,12 +26,12 @@
:error.import/invalid-token-name
{:error/code :error.import/invalid-token-name
:error/fn #(tr "errors.tokens.invalid-json-token-name")
:error/detail #(tr "errors.tokens.invalid-json-token-name-detail" %)}
:error/detail #(tr "errors.tokens.invalid-json-token-name-detail" (dom/escape-html %))}
:error.import/style-dictionary-reference-errors
{:error/code :error.import/style-dictionary-reference-errors
:error/fn #(str (tr "errors.tokens.import-error") "\n\n" (first %))
:error/detail #(str/join "\n\n" (rest %))}
:error/detail #(str/join "\n\n" (map dom/escape-html (rest %)))}
:error.import/style-dictionary-unknown-error
{:error/code :error.import/style-dictionary-reference-errors

View File

@ -16,6 +16,7 @@
[app.main.data.tokenscript :as ts]
[app.main.data.workspace.tokens.errors :as wte]
[app.main.store :as st]
[app.util.dom :as dom]
[app.util.i18n :as i18n]
[beicon.v2.core :as rx]
[cuerdas.core :as str]))
@ -54,14 +55,15 @@
(l/wrn :hint "unsupported token types found during import"
:tokens (str/join ", " (map (fn [[path type]] (str path " (" type ")")) unknown-tokens)))
(ntf/show {:content (i18n/tr "workspace.tokens.unknown-token-type-message")
:is-html true
:detail (->> (for [[token-type token-paths] type->tokens]
(str (i18n/tr "workspace.tokens.unknown-token-type-section"
token-type
(dom/escape-html token-type)
(i18n/tr "labels.warning-count" (i18n/c (count token-paths))))
"<ul>"
(->> token-paths
(sort)
(map #(str "<li>" % "</li>"))
(map #(str "<li>" (dom/escape-html %) "</li>"))
(str/join ""))
"</ul>"))
(str/join ""))

View File

@ -16,6 +16,7 @@
[app.common.geom.point :as gpt]
[app.common.types.modifiers :as ctm]
[app.main.data.helpers :as dsh]
[app.main.data.workspace :as-alias dw]
[app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.reflow :as wrf]
[app.main.data.workspace.shapes :as dwsh]
@ -159,7 +160,7 @@
(watch [_ state stream]
(wrf/start! reflow-task)
(if (= (::resize-wasm-text-debounce-event state) cur-event)
(let [stopper (->> stream (rx/filter (ptk/type? :app.main.data.workspace/finalize)))]
(let [stopper (->> stream (rx/filter (ptk/type? ::dw/finalize-workspace)))]
(rx/concat
(rx/merge
(->> stream
@ -179,7 +180,7 @@
;; pending until the resize is applied. All exact tasks in the
;; batch are retained in state and finished by the cleanup.
(rx/of (fn [state]
(run! wrf/finish! (::resize-wasm-text-reflow-tasks state))
(wrf/finish-tasks! (::resize-wasm-text-reflow-tasks state))
(dissoc state
::resize-wasm-text-debounce-ids
::resize-wasm-text-reflow-tasks
@ -198,15 +199,15 @@
content (dm/get-in objects [id :content])
fonts (wasm.fonts/get-content-fonts content)
fonts-loaded?
fonts-ready?
(->> fonts
(every?
(fn [font]
(let [font-data (wasm.fonts/make-font-data font)]
(wasm.fonts/font-stored? font-data (:emoji? font-data))))))
(wasm.fonts/font-ready? font-data)))))
resize-wasm-stream
(if fonts-loaded?
(if fonts-ready?
(let [pass-opts (when (or (some? undo-group) (some? undo-id))
(cond-> {}
(some? undo-group) (assoc :undo-group undo-group)
@ -232,15 +233,32 @@
(watch [_ state stream]
(let [resize-stream
(->> (rx/from ids)
(rx/map #(resize-wasm-text-debounce % opts)))]
(rx/map #(resize-wasm-text-debounce % opts)))
buffer-finished-stream
(->> (rx/merge
(->> stream
(rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit))
(rx/map (constantly :commit)))
;; Let a buffered commit beat the stop signal.
(->> stream
(rx/filter (ptk/type? ::dwsh/update-shapes-buffer-stop))
(rx/observe-on :async)
(rx/map (constantly :stop)))
(->> stream
(rx/filter (ptk/type? ::dw/finalize-workspace))
(rx/map (constantly :finalize))))
(rx/take 1))]
(if (::dwsh/update-shapes-buffer state)
;; If we're in the middle of a token propagation we wait until is finished to
;; recalculate the text sizes. The shapes stay pending for that whole wait,
;; since the per-shape debounce only marks them once dispatched.
(wrf/with-pending
:text-resize ids
(->> stream
(rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit))
(rx/take 1)
(rx/mapcat (constantly resize-stream))))
(->> buffer-finished-stream
(rx/mapcat
(fn [reason]
(if (= reason :finalize)
(rx/empty)
resize-stream)))))
resize-stream))))))

View File

@ -18,6 +18,7 @@
[app.util.globals :as globals]
[app.util.http :as http]
[app.util.object :as obj]
[app.util.timers :as tm]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[okulary.core :as l]
@ -222,8 +223,10 @@
(defmulti ^:private load-font :backend)
(defmethod load-font :default
[{:keys [backend] :as font}]
(log/wrn :msg "no implementation found for" :backend backend))
[{:keys [backend ::on-failed] :as font}]
(log/wrn :msg "no implementation found for" :backend backend)
(when (fn? on-failed)
(on-failed (ex-info "unsupported font backend" {:backend backend}))))
(defmethod load-font :builtin
[{:keys [id ::on-loaded] :as font}]
@ -247,23 +250,30 @@
[css]
(cfnt/gstatic->proxy-url css (u/join cf/public-uri "internal/gfonts/font")))
(defn- fetch-gfont-css
(defn- request-gfont-css
[url]
(->> (http/send! {:method :get :uri url :mode :cors :response-type :text})
(rx/map :body)
(rx/catch (fn [err]
(log/wrn :hint "cannot find the font" :cause err)
(rx/map :body)))
(defn- fetch-gfont-css
[url]
(->> (request-gfont-css url)
(rx/catch (fn [cause]
;; Keep CSS streams alive when a font cannot load.
(log/wrn :hint "cannot find the font" :cause cause)
(rx/empty)))))
(defmethod load-font :google
[{:keys [id ::on-loaded] :as font}]
[{:keys [id ::on-loaded ::on-failed] :as font}]
(when (globals/browser?)
(log/dbg :hint "load-font" :font-id id :backend "google")
(let [url (generate-gfonts-url font)]
(->> (fetch-gfont-css url)
;; Keep raw errors so the loader can use its fallback.
(->> (request-gfont-css url)
(rx/map process-gfont-css)
(rx/tap #(on-loaded id))
(rx/subs! (partial add-font-css! id)))
(rx/subs! (partial add-font-css! id)
#(when (fn? on-failed) (on-failed %))))
nil)))
;; --- LOADER: CUSTOM
@ -336,15 +346,30 @@
;; First caller, we create the promise and then wait
:else
(let [on-load (fn [resolve]
(swap! loaded conj font-id)
(swap! loading dissoc font-id)
(resolve font-id))
(let [settle! (fn [resolve loaded?]
;; Defer cleanup until a synchronous load is cached.
(tm/schedule
#(do
(when loaded?
(swap! loaded conj font-id))
(swap! loading dissoc font-id)
(resolve font-id))))
on-load (fn [resolve]
(settle! resolve true))
on-failed
(fn [resolve cause]
(log/wrn :hint "font load failed; using fallback"
:font-id font-id
:cause cause)
(settle! resolve false))
load-p (-> (p/create
(fn [resolve _]
(-> font
(assoc ::on-loaded (partial on-load resolve))
(assoc ::on-failed (partial on-failed resolve))
(load-font))))
;; We need to wait for the font to be loaded
(p/then (partial p/delay 120)))]

View File

@ -27,6 +27,7 @@
[app.main.ui.dashboard.files :refer [files-section*]]
[app.main.ui.dashboard.fonts :refer [fonts-page* font-providers-page*]]
[app.main.ui.dashboard.import]
[app.main.ui.dashboard.layout-toggle :as lt]
[app.main.ui.dashboard.libraries :refer [libraries-page*]]
[app.main.ui.dashboard.projects :refer [projects-section*]]
[app.main.ui.dashboard.search :refer [search-page*]]
@ -51,7 +52,7 @@
(mf/defc dashboard-content*
{::mf/private true}
[{:keys [team projects project section search-term profile default-project]}]
[{:keys [team projects project section search-term profile default-project layout on-layout-change]}]
(let [container (mf/use-ref)
content-width (mf/use-state 0)
@ -100,18 +101,18 @@
:dashboard-recent
(when (seq projects)
[:*
[:> projects-section*
{:team team
:projects projects
:profile profile}]
[:> projects-section* {:team team
:projects projects
:profile profile
:layout layout
:on-layout-change on-layout-change}]
(when ^boolean show-templates?
[:> templates-section*
{:profile profile
:project-id project-id
:team-id team-id
:default-project-id default-project-id
:content-width @content-width}])])
[:> templates-section* {:profile profile
:project-id project-id
:team-id team-id
:default-project-id default-project-id
:content-width @content-width}])])
:dashboard-fonts
[:> fonts-page* {:team team}]
@ -123,14 +124,15 @@
(when project
[:*
[:> files-section* {:team team
:project project}]
:project project
:layout layout
:on-layout-change on-layout-change}]
(when ^boolean show-templates?
[:> templates-section*
{:profile profile
:team-id team-id
:project-id project-id
:default-project-id default-project-id
:content-width @content-width}])])
[:> templates-section* {:profile profile
:team-id team-id
:project-id project-id
:default-project-id default-project-id
:content-width @content-width}])])
:dashboard-search
[:> search-page* {:team team
@ -155,7 +157,9 @@
:dashboard-deleted
[:> deleted-section* {:team team
:projects projects
:profile profile}]
:profile profile
:layout layout
:on-layout-change on-layout-change}]
nil)]))
@ -313,7 +317,15 @@
(mf/with-memo [projects]
(->> projects
(filter :is-default)
(first)))]
(first)))
layout* (hooks/use-persisted-state lt/layout-key lt/default-layout)
layout (deref layout*)
on-layout-change
(mf/use-fn
(fn [value]
(reset! layout* (keyword value))))]
(hooks/use-shortcuts ::dashboard sc/shortcuts-dashboard :dashboard)
@ -347,22 +359,22 @@
;; team is already set so don't put the team into mf/deps.
[:main {:class (stl/css :dashboard)
:key (dm/str (:id team))}
[:> sidebar*
{:team team
:projects projects
:project project
:default-project default-project
:profile profile
:section section
:search-term search-term}]
[:> dashboard-content*
{:projects projects
:profile profile
:project project
:default-project default-project
:section section
:search-term search-term
:team team}]]]))
[:> sidebar* {:team team
:projects projects
:project project
:default-project default-project
:profile profile
:section section
:search-term search-term}]
[:> dashboard-content* {:projects projects
:profile profile
:project project
:default-project default-project
:section section
:search-term search-term
:team team
:layout layout
:on-layout-change on-layout-change}]]]))
(mf/defc dashboard-page*
{::mf/lazy-load true}

View File

@ -219,16 +219,8 @@
(tr "labels.deleted")]]]))
(mf/defc deleted-section*
[{:keys [team projects]}]
(let [layout* (hooks/use-persisted-state lt/layout-key lt/default-layout)
layout (deref layout*)
on-layout-change
(mf/use-fn
(fn [value]
(reset! layout* (keyword value))))
deleted-map
[{:keys [team projects layout on-layout-change]}]
(let [deleted-map
(mf/deref ref:deleted-files)
projects

View File

@ -137,7 +137,7 @@
:on-import on-import}])]]))
(mf/defc files-section*
[{:keys [project team]}]
[{:keys [project team layout on-layout-change]}]
(let [files (mf/deref refs/files)
project-id (get project :id)
@ -147,7 +147,6 @@
(sort-by :modified-at)
(reverse)))
can-edit? (-> team :permissions :can-edit)
project-id (:id project)
is-draft-proyect (:is-default project)
@ -155,19 +154,16 @@
[rowref limit] (hooks/use-dynamic-grid-item-width)
file-count (or (count files) 0)
loading? (and (some? (:count project))
(not= (:count project) file-count))
empty-state-viewer (and (not can-edit?)
(= 0 file-count))
(= 0 file-count)
(not loading?))
selected-files (mf/deref refs/selected-files)
layout* (hooks/use-persisted-state lt/layout-key lt/default-layout)
layout (deref layout*)
on-layout-change
(mf/use-fn
(fn [value]
(reset! layout* (keyword value))))
on-file-created
(mf/use-fn
(fn [file-data]
@ -216,7 +212,7 @@
(tr "dashboard.empty-placeholder-drafts-subtitle")
(tr "dashboard.empty-placeholder-files-subtitle"))}]
[:> grid* {:project project
:files files
:files (if loading? nil files)
:selected-files selected-files
:can-edit can-edit?
:origin :files

View File

@ -109,6 +109,10 @@
team-id (get team :id)
file-count (or (:count project) 0)
loading? (and (pos? (:count project))
(empty? files))
is-draft? (:is-default project)
empty? (and (not can-edit)
(= 0 file-count))
@ -292,7 +296,7 @@
[:> line-grid* {:project project
:team team
:files files
:files (if loading? nil files)
:create-fn create-file
:can-edit can-edit
:limit limit
@ -313,7 +317,7 @@
(l/derived :recent-files st/state))
(mf/defc projects-section*
[{:keys [team projects profile]}]
[{:keys [team projects profile layout on-layout-change]}]
(let [team-id (get team :id)
@ -334,14 +338,6 @@
show-deleted? (:can-edit permisions)
layout* (hooks/use-persisted-state lt/layout-key lt/default-layout)
layout (deref layout*)
on-layout-change
(mf/use-fn
(fn [value]
(reset! layout* (keyword value))))
projects
(mf/with-memo [projects]
(->> projects

View File

@ -59,5 +59,7 @@
(when detail
[:details {:class (stl/css :error-detail)}
[:summary {:class (stl/css :error-detail-summary)} (tr "workspace.notification-pill.detail")]
[:div {:class (stl/css :error-detail-content)
:dangerouslySetInnerHTML #js {:__html detail}}]])]))
(if is-html
[:div {:class (stl/css :error-detail-content)
:dangerouslySetInnerHTML #js {:__html detail}}]
[:div {:class (stl/css :error-detail-content)} detail])])]))

View File

@ -111,11 +111,16 @@
:value (tr "nitrate.code-activation.submit")
:on-click on-accept}]]
[:div {:class (stl/css :footer-text)}
(tr "nitrate.code-activation.footer-before")
[:a {:class (stl/css :link)
:on-click on-download-request-click}
(tr "nitrate.code-activation.footer-link")]
(tr "nitrate.code-activation.footer-after") " "
[:a {:class (stl/css :link)
:href "mailto:sales@nitrate.com"}
"sales@nitrate.com"]]]]]))
[:div {:class (stl/css :code-label)} (tr "nitrate.code-activation.footer-title")]
[:div
[:a {:class (stl/css :link)
:on-click on-download-request-click}
(tr "nitrate.code-activation.footer-download")]]
[:div
(tr "nitrate.code-activation.footer-after") " "
[:a {:class (stl/css :link)
:href "mailto:sales@nitrate.com"}
"sales@nitrate.com"]
" "
(tr "nitrate.code-activation.footer-before")]]]]]))

View File

@ -102,9 +102,9 @@
(tr "nitrate.form.cancel-anytime")]]]
[:p {:class (stl/css :modal-text-medium)}
(tr "nitrate.form.subscribe-with-code") " " [:a {:class (stl/css :link)
:on-click on-activate-click}
(tr "nitrate.form.enter-code")]]
[:a {:class (stl/css :link)
:on-click on-activate-click}
(tr "nitrate.form.subscribe-with-code")]]
[:p {:class (stl/css :modal-text-medium)}
[:a {:class (stl/css :link)

View File

@ -12,6 +12,7 @@
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh]
[app.common.geom.shapes.text :as gsht]
[app.common.logging :as log]
[app.common.math :as mth]
[app.common.types.modifiers :as ctm]
[app.common.types.text :as txt]
@ -96,9 +97,12 @@
(st/emit! (dwt/resize-text id width height)))))
(st/emit! (dwt/clean-text-modifier id))))
;; Swallowed so a text whose position data cannot be computed still
;; settles and still reports its measurement as finished.
(p/catch (fn [_] nil))))
;; Always clear the task and log measurement errors.
(p/catch (fn [cause]
(log/error :hint "Could not measure text shape"
:shape-id id
:cause cause)
nil))))
(defn- update-text-modifier
[{:keys [grow-type id] :as shape} node]

View File

@ -28,7 +28,6 @@
[app.main.data.workspace.groups :as dwg]
[app.main.data.workspace.media :as dwm]
[app.main.data.workspace.pages :as dwpg]
[app.main.data.workspace.reflow :as wrf]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.variants :as dwv]
[app.main.data.workspace.wasm-text :as dwwt]
@ -47,6 +46,7 @@
[app.plugins.local-storage :as local-storage]
[app.plugins.page :as page]
[app.plugins.parser :as parser]
[app.plugins.reflow :as wrfp]
[app.plugins.shape :as shape]
[app.plugins.system-events :as se]
[app.plugins.user :as user]
@ -416,7 +416,10 @@
(cb/with-objects (:objects page))
(cb/add-object shape))]
(st/emit! (ch/commit-changes changes)
;; Track the commit until the renderer starts.
(st/emit! (ptk/data-event :text/reflow {:ids [(:id shape)]
:page-id (:id page)})
(ch/commit-changes changes)
(se/event plugin-id "create-shape" :type :text))
(when (features/active-feature? @st/state "render-wasm/v1")
@ -734,10 +737,5 @@
:waitForLayoutUpdate
(fn [timeout]
;; Always a promise, so a bad argument travels as a rejection.
(if (u/valid-timeout? timeout)
;; Resolves once every shape with reflow work in flight has settled.
(wrf/wait-for-layout-update timeout)
(js/Promise.
(fn [_ reject]
(u/reject-not-valid reject :waitForLayoutUpdate timeout)))))))
;; Resolves once every shape with reflow work in flight has settled.
(wrfp/wait-for-layout-update timeout))))

View File

@ -0,0 +1,77 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.plugins.reflow
"Promise adapter for the plugin `waitForLayoutUpdate` methods. Owns the
argument validation, the default deadline and the rejection shape; the
workspace only reports when its pending work has drained."
(:require
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.uuid :as uuid]
[app.main.data.workspace.reflow :as wrf]
[beicon.v2.core :as rx]))
;; Ceiling for callers that pass no timeout, so a pipeline that never drains
;; its marks rejects the promise rather than leaving it unsettled.
(def ^:private default-timeout 30000)
;; Largest value a signed 32-bit timer accepts.
(def ^:private max-timeout 2147483647)
(defn- valid-timeout?
"Checks that a plugin timeout fits a signed 32-bit timer."
[value]
(or (nil? value)
(and (number? value)
(pos? value)
(<= value max-timeout)
(js/Number.isFinite value))))
(defn- reject-invalid!
[reject value]
(let [msg (dm/str "[PENPOT PLUGIN] Value not valid: " value
". Code: " :waitForLayoutUpdate)]
(.error js/console msg)
(reject (js/Error. msg))))
(defn shape-wait-ids
"Ids a per-shape wait covers: the shape subtree, its ancestors, and the file
its components sync from."
[objects file-id id]
(-> (into #{} (cfh/get-children-ids-with-self objects id))
(into (cfh/get-parent-ids objects id))
(conj file-id)
(disj uuid/zero)))
(defn wait-for-layout-update
"Returns a JS Promise that resolves once every id in `ids` has drained from
the workspace pending map. A nil `ids` waits for every pending id; an empty
one has nothing to wait for and resolves right away.
The promise is rejected when `timeout` (ms) is not a valid timer value, or
when it elapses first; a nil `timeout` uses `default-timeout`."
([timeout]
(wait-for-layout-update nil timeout))
([ids timeout]
(js/Promise.
(fn [resolve reject]
(if-not (valid-timeout? timeout)
(reject-invalid! reject timeout)
;; Race the settle signal against the deadline; the loser is
;; unsubscribed. `settled` replays on subscribe, so an already drained
;; map wins even against a 1ms deadline.
(->> (rx/race (->> (rx/of :timeout)
(rx/delay (or timeout default-timeout)))
(->> (wrf/settled ids)
(rx/map (constantly :ok))))
(rx/take 1)
(rx/subs!
(fn [value]
(if (= value :timeout)
(reject (js/Error. "waitForLayoutUpdate timeout"))
(resolve)))
reject)))))))

View File

@ -42,7 +42,6 @@
[app.main.data.workspace.guides :as dwgu]
[app.main.data.workspace.interactions :as dwi]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.reflow :as wrf]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.shapes :as dwsh]
@ -58,6 +57,7 @@
[app.plugins.format :as format]
[app.plugins.grid :as grid]
[app.plugins.parser :as parser]
[app.plugins.reflow :as wrfp]
[app.plugins.register :as r]
[app.plugins.ruler-guides :as rg]
[app.plugins.shadows :as shadows]
@ -1057,15 +1057,11 @@
:waitForLayoutUpdate
(fn [timeout]
;; Always a promise, so a bad argument travels as a rejection.
(if (u/valid-timeout? timeout)
;; Resolves once the reflow work of this shape's subtree has
;; settled: it can be marked on the shape or on its descendants.
(let [objects (u/locate-objects file-id page-id)]
(wrf/wait-for-layout-update (cfh/get-children-ids-with-self objects id) timeout))
(js/Promise.
(fn [_ reject]
(u/reject-not-valid reject :waitForLayoutUpdate timeout)))))
;; Wait for layout work that can affect this shape.
(let [objects (u/locate-objects file-id page-id)]
(wrfp/wait-for-layout-update
(wrfp/shape-wait-ids objects file-id id)
timeout)))
;; Plugin data
:getPluginData

View File

@ -499,10 +499,10 @@
(u/not-valid plugin-id :growType "Cannot modify a page that is not currently active")
:else
(st/emit!
(dwsh/update-shapes [id] #(assoc % :grow-type value))
(when (features/active-feature? @st/state "render-wasm/v1")
(st/emit! (dwwt/resize-wasm-text-debounce id)))))))}
(do
(st/emit! (dwsh/update-shapes [id] #(assoc % :grow-type value)))
(when (features/active-feature? @st/state "render-wasm/v1")
(st/emit! (dwwt/resize-wasm-text-debounce id)))))))}
{:name "fontId"
:get #(-> % u/proxy->shape text-props :font-id format/format-mixed)

View File

@ -291,14 +291,6 @@
(throw-not-valid code value)
(display-not-valid code value)))
(defn valid-timeout?
"A plugin timeout argument: omitted, or a finite positive number of msecs."
[value]
(or (nil? value)
(and (number? value)
(pos? value)
(js/Number.isFinite value))))
(defn reject-not-valid
[reject code value]
(let [msg (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code)]

View File

@ -30,11 +30,30 @@
(def ^:private custom-fonts
(l/derived :fonts st/state))
;; Emits the font-id of every font whose glyphs wasm can already shape and
;; measure with. The browser-side loading of `app.main.fonts` is a separate
;; signal: it only says the DOM can render the font.
;; Emits every font face that WASM can measure.
(defonce font-stored-stream (rx/subject))
;; Emits failed font faces so layout can fall back.
(defonce font-storage-failed-stream (rx/subject))
;; Stores faces that currently use WASM fallbacks.
(defonce ^:private failed-font-data-keys (atom #{}))
(defn font-data-key
"Returns the identity WASM uses to distinguish stored faces in one family."
[font-data]
(select-keys font-data [:font-id :weight :style :emoji?]))
(defn- clear-font-storage-failure!
[font-data]
(swap! failed-font-data-keys disj (font-data-key font-data)))
(defn- report-font-storage-failed!
[font-data]
(let [key (font-data-key font-data)]
(swap! failed-font-data-keys conj key)
(rx/push! font-storage-failed-stream key)))
(def ^:private default-font-size 14)
(def ^:private default-line-height 1.2)
(def ^:private default-letter-spacing 0.0)
@ -121,39 +140,91 @@
(:style font-data)
emoji?
fallback?)
(clear-font-storage-failure! font-data)
;; Reported after the store call: subscribers react by measuring text.
(rx/push! font-stored-stream (:font-id font-data))
(rx/push! font-stored-stream (font-data-key font-data))
true)))
;; Tracks fonts currently being fetched: {url -> fallback?}
;; When the same font is requested as both primary and fallback,
;; the fallback flag is upgraded to true so it gets registered
;; in WASM's fallback_fonts set.
;; Tracks every font face waiting on each shared request.
(def fetching (atom {}))
(defn- register-font-fetch!
[font-url font-data emoji? fallback?]
(let [key (font-data-key font-data)]
(clear-font-storage-failure! font-data)
(swap! fetching
update-in
[font-url key]
(fn [request]
{:font-data font-data
:emoji? emoji?
:fallback? (or fallback? (:fallback? request))}))))
(defn- take-font-fetches!
[font-url]
(let [requests (vals (get @fetching font-url))]
(swap! fetching dissoc font-url)
requests))
(defn- fail-font-fetches!
[font-url cause]
(let [requests (take-font-fetches! font-url)]
(log/error :hint "Could not fetch font"
:font-url font-url
:cause cause)
(doseq [{:keys [font-data]} requests]
(report-font-storage-failed! font-data))))
(defn- store-font-fetch!
[body {:keys [font-data emoji? fallback?]}]
(try
(let [stored? (store-font-buffer font-data body emoji? fallback?)]
(when-not stored?
(report-font-storage-failed! font-data))
stored?)
(catch :default cause
(log/error :hint "Could not store font"
:font-id (:font-id font-data)
:cause cause)
(report-font-storage-failed! font-data)
false)))
(defn- fetch-font
[font-data font-url emoji? fallback?]
(if (contains? @fetching font-url)
(do (when fallback? (swap! fetching assoc font-url true))
nil)
(cond
(nil? font-url)
;; Fail missing font assets without sharing a nil request.
(do
(swap! fetching assoc font-url fallback?)
(clear-font-storage-failure! font-data)
(tm/schedule #(report-font-storage-failed! font-data))
nil)
(contains? @fetching font-url)
(do
(register-font-fetch! font-url font-data emoji? fallback?)
nil)
:else
(do
(register-font-fetch! font-url font-data emoji? fallback?)
{:key font-url
:callback
(fn []
(->> (http/send! {:method :get
:uri font-url
:response-type :buffer})
(rx/map (fn [{:keys [body]}]
(let [fallback? (get @fetching font-url fallback?)]
(swap! fetching dissoc font-url)
(store-font-buffer font-data body emoji? fallback?))))
(rx/catch (fn [cause]
(swap! fetching dissoc font-url)
(log/error :hint "Could not fetch font"
:font-url font-url
:cause cause)
(rx/empty)))))})))
(try
(->> (http/send! {:method :get
:uri font-url
:response-type :buffer})
(rx/map
(fn [{:keys [body]}]
(let [requests (take-font-fetches! font-url)]
(mapv (partial store-font-fetch! body) requests))))
(rx/catch
(fn [cause]
(fail-font-fetches! font-url cause)
(rx/empty))))
(catch :default cause
(fail-font-fetches! font-url cause)
(rx/empty))))})))
(defn- google-font-ttf-url
[font-id font-variant-id font-weight font-style]
@ -183,9 +254,15 @@
(:style font-data)
emoji?))))
(defn font-ready?
"Returns true when WASM can lay out with the requested face or its fallback."
[font-data]
(or (contains? @failed-font-data-keys (font-data-key font-data))
(font-stored? font-data (:emoji? font-data))))
(defn- store-font-id
[font-data asset-id emoji? fallback?]
(when asset-id
(if asset-id
(let [uri (font-id->ttf-url
(:font-id font-data) asset-id
(:font-variant-id font-data)
@ -197,8 +274,16 @@
(if font-stored?
;; Deferred so consumers, which subscribe after dispatching the sync
;; that lands here, are listening when an already-stored font reports.
(tm/schedule #(rx/push! font-stored-stream (:font-id font-data)))
(fetch-font font-data uri emoji? fallback?)))))
(do
(clear-font-storage-failure! font-data)
(tm/schedule #(rx/push! font-stored-stream (font-data-key font-data))))
(fetch-font font-data uri emoji? fallback?)))
;; Report missing font assets asynchronously.
(do
(clear-font-storage-failure! font-data)
(tm/schedule
#(report-font-storage-failed! font-data))
nil)))
(defn serialize-font-style
[font-style]

View File

@ -6,13 +6,24 @@
(ns frontend-tests.data.workspace-reflow-test
"Tests the reflow tasks the layout and text pipelines feed to
`app.main.data.workspace.reflow`, which is what plugin waits observe."
`app.main.data.workspace.reflow`, which is what plugin waits observe. The
promise view of the settle signal lives in `app.plugins.reflow`; these tests
use it because it is the wait the plugin API ships."
(:require
[app.common.uuid :as uuid]
[app.main.data.workspace.reflow :as wrf]
[app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.texts :as dwtxt]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.fonts :as fonts]
[app.plugins.reflow :as pwrf]
[app.render-wasm.api.fonts :as wasm.fonts]
[app.util.globals :as globals]
[app.util.http :as http]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock]
[potok.v2.core :as ptk]))
(t/use-fixtures :each {:before wrf/reset-pending!
@ -43,7 +54,7 @@
(t/async done
(let [store (start-pipeline!)]
(ptk/emit! store (ptk/data-event :layout/update {:ids [uuid/zero]}))
(-> (wrf/wait-for-layout-update nil 20)
(-> (pwrf/wait-for-layout-update nil 20)
(.then #(t/is true "resolved with no pending work"))
(.catch #(t/is false "a root-only update was marked as pending work"))
(.then (fn []
@ -59,16 +70,41 @@
_ (wrf/reset-pending!)
current-task (wrf/start! :text-measure [id])]
(wrf/finish! stale-task)
(-> (wrf/wait-for-layout-update [id] 20)
(-> (pwrf/wait-for-layout-update [id] 20)
(.then #(t/is false "a stale completion drained current work"))
(.catch #(t/is true "current work stayed pending"))
(.then (fn []
(wrf/finish! current-task)
(wrf/wait-for-layout-update [id] 100)))
(pwrf/wait-for-layout-update [id] 100)))
(.then #(t/is true "the exact current task drained normally"))
(.catch #(t/is false "the current task did not drain"))
(.then (fn [] (done)))))))
(t/deftest reinstalling-the-pending-scan-resets-work-and-keeps-tracking
;; Reinstall the pending scan with the latest reducer.
(t/async done
(let [id (uuid/next)
stale (wrf/start! :text-measure [id])
current* (atom nil)]
(#'wrf/install-pending-subscription!)
(-> (pwrf/wait-for-layout-update [id] 100)
(.then #(t/is true "reinstalling the scan reset its previous generation"))
(.catch #(t/is false "the replaced scan kept stale work pending"))
(.then
(fn []
(reset! current* (wrf/start! :text-measure [id]))
(pwrf/wait-for-layout-update [id] 20)))
(.then #(t/is false "the replacement scan did not track new work"))
(.catch #(t/is true "the replacement scan tracked new work"))
(.then
(fn []
(wrf/finish! stale)
(wrf/finish! @current*)
(pwrf/wait-for-layout-update [id] 100)))
(.then #(t/is true "the replacement scan drained its exact task"))
(.catch #(t/is false "the replacement scan did not drain"))
(.then (fn [] (done)))))))
(t/deftest pending-promise-finishes-at-the-operation-boundary
;; Imperative render work is pending from before its thunk starts until the
;; exact promise returned by that thunk settles; no timer is involved.
@ -82,12 +118,12 @@
(fn []
(reset! started? true)
(js/Promise. (fn [resolve _] (reset! resolve* resolve)))))
(-> (wrf/wait-for-layout-update [id] 20)
(-> (pwrf/wait-for-layout-update [id] 20)
(.then #(t/is false "resolved while the render operation was pending"))
(.catch #(t/is @started? "the task was opened before running the operation"))
(.then (fn []
(@resolve*)
(wrf/wait-for-layout-update [id] 100)))
(pwrf/wait-for-layout-update [id] 100)))
(.then #(t/is true "resolved as soon as the render operation settled"))
(.catch #(t/is false "the settled render operation stayed pending"))
(.then (fn [] (done)))))))
@ -98,7 +134,7 @@
(wrf/run-pending! :text-measure [id] #(throw (js/Error. "boom")))
(catch :default _))
(t/async done
(-> (wrf/wait-for-layout-update [id] 100)
(-> (pwrf/wait-for-layout-update [id] 100)
(.then #(t/is true "a synchronous failure drained its exact task"))
(.catch #(t/is false "a synchronous failure leaked pending work"))
(.then (fn [] (done)))))))
@ -110,10 +146,10 @@
task-a (wrf/start! :text-bridge [id-a])
task-b (wrf/start! :text-bridge [id-b])]
(wrf/cancel-shapes! [id-a])
(-> (wrf/wait-for-layout-update [id-a] 100)
(-> (pwrf/wait-for-layout-update [id-a] 100)
(.then #(t/is true "deleted shape work was cancelled"))
(.catch #(t/is false "deleted shape work stayed pending"))
(.then #(wrf/wait-for-layout-update [id-b] 20))
(.then #(pwrf/wait-for-layout-update [id-b] 20))
(.then #(t/is false "cancelling one shape drained its sibling"))
(.catch #(t/is true "sibling work stayed pending"))
(.then (fn []
@ -130,28 +166,290 @@
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a id-b]}))
(let [task-a (wrf/start! :text-measure [id-a])]
(wrf/finish! task-a))
(-> (wrf/wait-for-layout-update [id-b] 20)
(-> (pwrf/wait-for-layout-update [id-b] 20)
(.then #(t/is false "the first text released its sibling bridge"))
(.catch #(t/is true "the sibling bridge stayed pending"))
(.then (fn []
(let [task-b (wrf/start! :text-measure [id-b])]
(wrf/finish! task-b))
(wrf/wait-for-layout-update [id-b] 100)))
(pwrf/wait-for-layout-update [id-b] 100)))
(.then #(t/is true "the sibling drained after its own measurement"))
(.catch #(t/is false "the sibling never drained"))
(.then (fn []
(stop-text-pipeline! store)
(done)))))))
(t/deftest text-bridge-observes-out-of-order-work
;; Start all bridges before matching work can finish.
(t/async done
(let [store (start-text-pipeline!)
id-a (uuid/next)
id-b (uuid/next)]
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a id-b]}))
(let [task-b (wrf/start! :text-measure [id-b])]
(wrf/finish! task-b))
(let [task-a (wrf/start! :text-measure [id-a])]
(wrf/finish! task-a))
(-> (pwrf/wait-for-layout-update [id-a id-b] 100)
(.then #(t/is true "both out-of-order bridges observed their work"))
(.catch #(t/is false "a bridge missed work that started out of order"))
(.then (fn []
(stop-text-pipeline! store)
(done)))))))
(t/deftest text-bridge-does-not-consume-preexisting-work
;; Ignore matching work that started before the bridge.
(t/async done
(let [store (start-text-pipeline!)
id (uuid/next)
prior-task (wrf/start! :text-measure [id])]
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id]}))
(wrf/finish! prior-task)
(-> (pwrf/wait-for-layout-update [id] 20)
(.then #(t/is false "preexisting work released the new bridge"))
(.catch #(t/is true "the new bridge remained pending"))
(.then (fn []
(let [current-task (wrf/start! :text-measure [id])]
(wrf/finish! current-task))
(pwrf/wait-for-layout-update [id] 100)))
(.then #(t/is true "work started after the bridge drained it"))
(.catch #(t/is false "the causal measurement did not drain the bridge"))
(.then (fn []
(stop-text-pipeline! store)
(done)))))))
(t/deftest cancelling-a-bridge-does-not-block-later-reflow-events
(t/async done
(let [store (start-text-pipeline!)
id-a (uuid/next)
id-b (uuid/next)]
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a]}))
(wrf/cancel-shapes! [id-a])
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id-b]}))
(-> (pwrf/wait-for-layout-update [id-b] 20)
(.then #(t/is false "the later bridge was not opened"))
(.catch #(t/is true "the later bridge stayed pending"))
(.then (fn []
(let [task-b (wrf/start! :text-measure [id-b])]
(wrf/finish! task-b))
(pwrf/wait-for-layout-update [id-b] 100)))
(.then #(t/is true "the later bridge drained after its own work"))
(.catch #(t/is false "the cancelled bridge blocked the pipeline"))
(.then (fn []
(stop-text-pipeline! store)
(done)))))))
(t/deftest finalizing-a-page-cancels-its-text-bridges
(t/async done
(let [store (start-text-pipeline!)
id (uuid/next)]
(ptk/emit! store (ptk/data-event :text/reflow {:ids [id]}))
(ptk/emit! store (ptk/data-event :app.main.data.workspace.pages/finalize-page))
(-> (pwrf/wait-for-layout-update [id] 100)
(.then #(t/is true "page teardown drained the unmeasured text bridge"))
(.catch #(t/is false "page teardown left text work pending"))
(.then (fn []
(stop-text-pipeline! store)
(done)))))))
(t/deftest failed-wasm-font-storage-falls-back-and-drains
(t/async done
(let [id (uuid/next)
font-key {:font-id "gfont-does-not-load"
:weight 400
:style 0
:emoji? false}
stream (rx/subject)
events (atom [])]
(->> (#'dwtxt/await-font-faces stream #{font-key} [id])
(rx/subs! #(swap! events conj %)))
(#'wasm.fonts/report-font-storage-failed! font-key)
(-> (pwrf/wait-for-layout-update [id] 100)
(.then (fn []
(t/is (= 1 (count @events))
"font failure dispatches one fallback resize")
(t/is (wasm.fonts/font-ready? font-key)
"the resize gate accepts the failed face's fallback")
(done)))
(.catch (fn [_]
(t/is false "font failure leaked pending work")
(done)))))))
(t/deftest failed-dom-font-load-falls-back-and-drains
(t/async done
(let [id (uuid/next)
font-id "gfont-layout-failure-test"]
(swap! fonts/fontsdb assoc font-id
{:id font-id
:backend :google
:family "Layout Failure Test"
:variants [{:id "regular"}]})
(swap! fonts/loaded disj font-id)
(swap! fonts/loading dissoc font-id)
(mock/with-mocks
{globals/browser? (constantly true)
http/send! (fn [_] (rx/throw (js/Error. "font fetch failed")))}
(fn [done']
(wrf/run-pending! :font [id] #(fonts/ensure-loaded! font-id))
(-> (pwrf/wait-for-layout-update [id] 500)
(.then (fn []
(t/is (not (contains? @fonts/loading font-id))
"a failed load must not remain cached as loading")))
(.catch #(t/is false "failed DOM font load leaked pending work"))
(.then (fn []
(swap! fonts/fontsdb dissoc font-id)
(swap! fonts/loaded disj font-id)
(swap! fonts/loading dissoc font-id)
(done')))))
done))))
(t/deftest failed-google-font-css-does-not-abort-shared-consumers
(t/async done
(let [font-id "gfont-optional-css-test"
values (atom [])
cleanup #(swap! fonts/fontsdb dissoc font-id)]
(swap! fonts/fontsdb assoc font-id
{:id font-id
:backend :google
:family "Optional CSS Test"
:variants [{:id "regular"}]})
(mock/with-mocks
{http/send! (fn [_] (rx/throw (js/Error. "font css fetch failed")))}
(fn [done']
(->> (fonts/fetch-font-css {:font-id font-id})
(rx/subs!
#(swap! values conj %)
(fn [_]
(cleanup)
(t/is false "an optional font CSS failure escaped the shared helper")
(done'))
(fn []
(cleanup)
(t/is (empty? @values)
"a failed optional font contributes no CSS")
(done')))))
done))))
(t/deftest deduplicated-wasm-font-failure-settles-every-face
(t/async done
(let [font-url "https://example.test/shared-font.ttf"
regular {:font-id "gfont-shared-regular"
:weight 400
:style 0
:emoji? false}
bold {:font-id "gfont-shared-bold"
:weight 700
:style 0
:emoji? false}]
(mock/with-mocks
{http/send! (fn [_] (rx/throw (js/Error. "shared fetch failed")))}
(fn [done']
(let [request (#'wasm.fonts/fetch-font regular font-url false false)
duplicate (#'wasm.fonts/fetch-font bold font-url false false)]
(t/is (some? request) "the first face owns the shared fetch")
(t/is (nil? duplicate) "the second face reuses the shared fetch")
(->> ((:callback request))
(rx/subs!
(fn [_])
(fn [_]
(t/is false "the shared fetch failure escaped its fallback")
(done'))
(fn []
(t/is (wasm.fonts/font-ready? regular)
"the first face settled through fallback")
(t/is (wasm.fonts/font-ready? bold)
"the deduplicated face settled through fallback")
(done'))))))
done))))
(t/deftest missing-wasm-font-url-settles-without-entering-fetch-map
(t/async done
(let [font-data {:font-id "gfont-missing-url"
:weight 400
:style 0
:emoji? false}]
(t/is (nil? (#'wasm.fonts/fetch-font font-data nil false false))
"a missing URL starts no request")
(t/is (not (contains? @wasm.fonts/fetching nil))
"missing URLs are not deduplicated under nil")
(js/setTimeout
(fn []
(t/is (wasm.fonts/font-ready? font-data)
"the missing face settled through fallback")
(done))
0))))
(t/deftest wasm-font-resize-waits-for-every-face
(t/async done
(let [id (uuid/next)
regular-key {:font-id "gfont-mixed"
:weight 400
:style 0
:emoji? false}
bold-key {:font-id "gfont-mixed"
:weight 700
:style 0
:emoji? false}
stream (rx/subject)
events (atom [])]
(->> (#'dwtxt/await-font-faces stream #{regular-key bold-key} [id])
(rx/subs! #(swap! events conj %)))
(rx/push! wasm.fonts/font-stored-stream regular-key)
(-> (pwrf/wait-for-layout-update [id] 20)
(.then #(t/is false "the first face released the font task"))
(.catch #(t/is true "the second face remained pending"))
(.then (fn []
(rx/push! wasm.fonts/font-storage-failed-stream bold-key)
(pwrf/wait-for-layout-update [id] 100)))
(.then (fn []
(t/is (= 1 (count @events))
"all faces settling dispatches exactly one resize")
(done)))
(.catch (fn [_]
(t/is false "the complete face set did not drain")
(done)))))))
(t/deftest buffered-wasm-resize-releases-on-stop-without-a-commit
(t/async done
(let [store (ptk/store {:state {} :on-error #(js/console.error %)})
id (uuid/next)]
(ptk/emit! store (dwsh/update-shapes-buffer-start))
(ptk/emit! store (dwwt/resize-wasm-text-all [id]))
(-> (pwrf/wait-for-layout-update [id] 20)
(.then #(t/is false "the buffered resize was not marked pending"))
(.catch #(t/is true "the resize stayed pending while the buffer was open"))
(.then (fn []
(ptk/emit! store (dwsh/update-shapes-buffer-stop))
(pwrf/wait-for-layout-update [id] 500)))
(.then #(t/is true "buffer stop released the fallback resize"))
(.catch #(t/is false "buffer stop without a commit leaked pending work"))
(.then (fn [] (done)))))))
(t/deftest buffered-wasm-resize-releases-on-workspace-finalize
(t/async done
(let [store (ptk/store {:state {} :on-error #(js/console.error %)})
id (uuid/next)]
(ptk/emit! store (dwsh/update-shapes-buffer-start))
(ptk/emit! store (dwwt/resize-wasm-text-all [id]))
(-> (pwrf/wait-for-layout-update [id] 20)
(.then #(t/is false "the buffered resize was not marked pending"))
(.catch #(t/is true "the resize stayed pending while the buffer was open"))
(.then (fn []
(ptk/emit! store (ptk/data-event :app.main.data.workspace/finalize-workspace))
(pwrf/wait-for-layout-update [id] 500)))
(.then #(t/is true "workspace finalization released the buffered resize"))
(.catch #(t/is false "workspace finalization leaked pending work"))
(.then (fn [] (done)))))))
(t/deftest layout-update-is-pending-until-the-buffer-flushes
;; A shape id is marked on arrival and drained when the update is processed.
(t/async done
(let [store (start-pipeline!)]
(ptk/emit! store (ptk/data-event :layout/update {:ids [(uuid/next) uuid/zero]}))
(-> (wrf/wait-for-layout-update nil 20)
(-> (pwrf/wait-for-layout-update nil 20)
(.then #(t/is false "resolved while the update was still buffered"))
(.catch #(t/is true "stayed pending until the flush"))
(.then #(wrf/wait-for-layout-update nil 5000))
(.then #(pwrf/wait-for-layout-update nil 5000))
(.then #(t/is true "resolved once the update was processed"))
(.catch #(t/is false "the pipeline never drained its mark"))
(.then (fn []

View File

@ -164,169 +164,151 @@
:team-id "b8f8bb52-8b70-8144-8004-4a5085f0bdc9"})
(t/deftest expired-organization-sso-navigates-to-identity-provider
(t/testing "the browser is sent to the identity provider instead of an error page"
(let [events (atom [])]
(with-redefs [rp/cmd!
(mock/stub
(fn [_command _params]
(rx/of {:authorized false
:redirect-uri "https://idp.example.com/authorize"})))
rt/get-current-href
(constantly workspace-href)
st/emit!
(mock/stub (fn [& emitted] (swap! events into emitted)))]
(errors/on-error (sso-required-error))
(t/is (= [::rt/nav-raw] (mapv ptk/type @events)))))))
(t/async done
(t/testing "the browser is sent to the identity provider instead of an error page"
(let [events (atom [])]
(mock/with-mocks
{rp/cmd! (mock/stub
(fn [_command _params]
(rx/of {:authorized false
:redirect-uri "https://idp.example.com/authorize"})))
rt/get-current-href (constantly workspace-href)
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))}
(fn [done']
(errors/on-error (sso-required-error))
(t/is (= [::rt/nav-raw] (mapv ptk/type @events)))
(done'))
done)))))
(t/deftest expired-organization-sso-comes-back-to-the-current-location
(t/testing "the SSO check asks the provider to return the user where they were"
(let [rpc-calls (atom [])]
(with-redefs [rp/cmd!
(mock/stub
(fn [command params]
(swap! rpc-calls conj {:command command :params params})
(rx/of {:authorized false
:redirect-uri "https://idp.example.com/authorize"})))
rt/get-current-href
(constantly workspace-href)
st/emit! mock/noop]
(errors/on-error (sso-required-error))
(t/is (= [{:command :check-nitrate-sso
:params {:team-id "b8f8bb52-8b70-8144-8004-4a5085f0bdc9"
:organization-id organization-id
:url workspace-href}}]
@rpc-calls))))))
(t/async done
(t/testing "the SSO check asks the provider to return the user where they were"
(let [rpc-calls (atom [])]
(mock/with-mocks
{rp/cmd! (mock/stub
(fn [command params]
(swap! rpc-calls conj {:command command :params params})
(rx/of {:authorized false
:redirect-uri "https://idp.example.com/authorize"})))
rt/get-current-href (constantly workspace-href)
st/emit! mock/noop}
(fn [done']
(errors/on-error (sso-required-error))
(t/is (= [{:command :check-nitrate-sso
:params {:team-id "b8f8bb52-8b70-8144-8004-4a5085f0bdc9"
:organization-id organization-id
:url workspace-href}}]
@rpc-calls))
(done'))
done)))))
(t/deftest already-satisfied-organization-sso-retries-the-location
(t/testing "a session renewed meanwhile (e.g. in another tab) reloads instead of erroring"
(let [events (atom [])]
(with-redefs [rp/cmd!
(mock/stub
(fn [_command _params]
(rx/of {:authorized true :reason :sso-satisfied})))
rt/get-current-href
(constantly workspace-href)
st/emit!
(mock/stub (fn [& emitted] (swap! events into emitted)))]
(errors/on-error (sso-required-error))
(t/is (= [::rt/reload] (mapv ptk/type @events)))))))
(t/async done
(t/testing "a session renewed meanwhile (e.g. in another tab) reloads instead of erroring"
(let [events (atom [])]
(mock/with-mocks
{rp/cmd! (mock/stub
(fn [_command _params]
(rx/of {:authorized true :reason :sso-satisfied})))
rt/get-current-href (constantly workspace-href)
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))}
(fn [done']
(errors/on-error (sso-required-error))
(t/is (= [::rt/reload] (mapv ptk/type @events)))
(done'))
done)))))
(t/deftest organization-sso-without-usable-provider-shows-the-sso-error-dialog
(t/testing "SSO is required but there is nowhere to go: offer a retry, not a permission error"
(let [assigned* (atom nil)]
(with-redefs [rp/cmd!
(mock/stub
(fn [_command _params]
(rx/of {:authorized false :redirect-uri nil})))
rt/get-current-href
(constantly workspace-href)
rt/assign-exception
(fn [error]
(reset! assigned* error)
(ptk/data-event ::assigned error))]
(errors/on-error (sso-required-error))
(t/is (= :sso-error (:type @assigned*)))
(t/is (= organization-id (:organization-id @assigned*)))
(t/is (true? (:is-workspace @assigned*)))))))
(t/async done
(t/testing "SSO is required but there is nowhere to go: offer a retry, not a permission error"
(let [assigned* (atom nil)]
(mock/with-mocks
{rp/cmd! (mock/stub
(fn [_command _params]
(rx/of {:authorized false :redirect-uri nil})))
rt/get-current-href (constantly workspace-href)
rt/assign-exception (fn [error]
(reset! assigned* error)
(ptk/data-event ::assigned error))}
(fn [done']
(errors/on-error (sso-required-error))
(t/is (= :sso-error (:type @assigned*)))
(t/is (= organization-id (:organization-id @assigned*)))
(t/is (true? (:is-workspace @assigned*)))
(done'))
done)))))
(t/deftest organization-sso-without-team-access-reports-a-permission-failure
(t/testing "a user who cannot reach the team keeps getting the authentication error"
(let [assigned* (atom nil)]
(with-redefs [rp/cmd!
(mock/stub
(fn [_command _params]
(rx/of {:authorized true :reason :no-team-access})))
rt/get-current-href
(constantly workspace-href)
rt/assign-exception
(fn [error]
(reset! assigned* error)
(ptk/data-event ::assigned error))]
(errors/on-error (sso-required-error))
(t/is (= :authentication (:type @assigned*)))
(t/is (= :nitrate-sso-required (:code @assigned*)))))))
(t/async done
(t/testing "a user who cannot reach the team keeps getting the authentication error"
(let [assigned* (atom nil)]
(mock/with-mocks
{rp/cmd! (mock/stub
(fn [_command _params]
(rx/of {:authorized true :reason :no-team-access})))
rt/get-current-href (constantly workspace-href)
rt/assign-exception (fn [error]
(reset! assigned* error)
(ptk/data-event ::assigned error))}
(fn [done']
(errors/on-error (sso-required-error))
(t/is (= :authentication (:type @assigned*)))
(t/is (= :nitrate-sso-required (:code @assigned*)))
(done'))
done)))))
(t/deftest organization-sso-does-not-retry-on-an-unexplained-authorization
(t/testing "reloading on an answer we don't understand would spin on the same rejection"
(let [events (atom [])]
(with-redefs [rp/cmd!
(mock/stub (fn [_command _params] (rx/of {:authorized true})))
rt/get-current-href
(constantly workspace-href)
rt/assign-exception
(fn [error] (ptk/data-event ::assigned error))
;; async-emit! is variadic-only, so the replacement must be
;; variadic too for the compiled static dispatch to find it
st/async-emit!
(fn [& emitted] (swap! events into emitted))]
(errors/on-error (sso-required-error))
(t/is (= [::assigned] (mapv ptk/type @events)))))))
(t/async done
(t/testing "reloading on an answer we don't understand would spin on the same rejection"
(let [events (atom [])]
(mock/with-mocks
{rp/cmd! (mock/stub (fn [_command _params] (rx/of {:authorized true})))
rt/get-current-href (constantly workspace-href)
rt/assign-exception (fn [error] (ptk/data-event ::assigned error))
st/async-emit! (fn [& emitted] (swap! events into emitted))}
(fn [done']
(errors/on-error (sso-required-error))
(t/is (= [::assigned] (mapv ptk/type @events)))
(done'))
done)))))
(t/deftest organization-sso-error-without-context-is-reported-as-it-arrives
(t/testing "with no organization and no team there is nothing to check"
(let [rpc-calls (atom 0)
assigned* (atom nil)]
(with-redefs [rp/cmd!
(mock/stub (fn [_command _params]
(swap! rpc-calls inc)
(rx/empty)))
rt/get-current-href
(constantly workspace-href)
rt/assign-exception
(fn [error]
(reset! assigned* error)
(ptk/data-event ::assigned error))]
(errors/on-error {:type :authentication
:code :nitrate-sso-required})
(t/is (zero? @rpc-calls))
(t/is (= :nitrate-sso-required (:code @assigned*)))))))
(t/async done
(t/testing "with no organization and no team there is nothing to check"
(let [rpc-calls (atom 0)
assigned* (atom nil)]
(mock/with-mocks
{rp/cmd! (mock/stub (fn [_command _params]
(swap! rpc-calls inc)
(rx/empty)))
rt/get-current-href (constantly workspace-href)
rt/assign-exception (fn [error]
(reset! assigned* error)
(ptk/data-event ::assigned error))}
(fn [done']
(errors/on-error {:type :authentication
:code :nitrate-sso-required})
(t/is (zero? @rpc-calls))
(t/is (= :nitrate-sso-required (:code @assigned*)))
(done'))
done)))))
(t/deftest a-resultless-organization-sso-check-does-not-wedge-later-rejections
(t/testing "the one-in-flight guard is released even when no answer arrives"
(let [rpc-calls (atom 0)]
(with-redefs [rp/cmd!
(mock/stub (fn [_command _params]
(swap! rpc-calls inc)
(rx/empty)))
rt/get-current-href
(constantly workspace-href)
st/emit! mock/noop]
(errors/on-error (sso-required-error))
(errors/on-error (sso-required-error))
(t/is (= 2 @rpc-calls))))))
(t/async done
(t/testing "the one-in-flight guard is released even when no answer arrives"
(let [rpc-calls (atom 0)]
(mock/with-mocks
{rp/cmd! (mock/stub (fn [_command _params]
(swap! rpc-calls inc)
(rx/empty)))
rt/get-current-href (constantly workspace-href)
st/emit! mock/noop}
(fn [done']
(errors/on-error (sso-required-error))
(errors/on-error (sso-required-error))
(t/is (= 2 @rpc-calls))
(done'))
done)))))
;; A failing check must stay a failing check: the generic handling turns it
;; into a toast, whereas swallowing it would show a permission error for

View File

@ -17,44 +17,54 @@
(def ^:private plugin-id "00000000-0000-0000-0000-000000000000")
(t/deftest comment-thread-remove-allows-the-owner
(let [owner-id (random-uuid)
file-id (random-uuid)
page-id (random-uuid)
thread-id (random-uuid)
emitted (atom nil)
thread (comments/comment-thread-proxy
plugin-id
file-id
page-id
{:id thread-id :owner-id owner-id})]
(set! st/state (atom {:profile {:id owner-id}}))
(with-redefs [r/check-permission (constantly true)
dc/delete-comment-thread-on-workspace
(mock/stub (fn [params callback]
(callback)
[:delete-thread params]))
st/emit! (mock/stub (fn [event] (reset! emitted event)))]
(let [result (.remove thread)]
(t/is (instance? js/Promise result))
(t/is (= [:delete-thread {:id thread-id}] @emitted))))))
(t/async done
(let [owner-id (random-uuid)
file-id (random-uuid)
page-id (random-uuid)
thread-id (random-uuid)
emitted (atom nil)
thread (comments/comment-thread-proxy
plugin-id
file-id
page-id
{:id thread-id :owner-id owner-id})]
(set! st/state (atom {:profile {:id owner-id}}))
(mock/with-mocks
{r/check-permission (constantly true)
dc/delete-comment-thread-on-workspace
(mock/stub (fn [params callback]
(callback)
[:delete-thread params]))
st/emit! (mock/stub (fn [event] (reset! emitted event)))}
(fn [done']
(let [result (.remove thread)]
(t/is (instance? js/Promise result))
(t/is (= [:delete-thread {:id thread-id}] @emitted))
(done')))
done))))
(t/deftest page-remove-comment-thread-emits-delete-event
(let [file-id (random-uuid)
page-id (random-uuid)
thread-id (random-uuid)
emitted (atom nil)
page (page/page-proxy plugin-id file-id page-id)
thread (comments/comment-thread-proxy
plugin-id
file-id
page-id
{:id thread-id :owner-id (random-uuid)})]
(with-redefs [r/check-permission (constantly true)
dc/delete-comment-thread-on-workspace
(mock/stub (fn [params callback]
(callback)
[:delete-thread params]))
st/emit! (mock/stub (fn [event] (reset! emitted event)))]
(let [result (.removeCommentThread page thread)]
(t/is (instance? js/Promise result))
(t/is (= [:delete-thread {:id thread-id}] @emitted))))))
(t/async done
(let [file-id (random-uuid)
page-id (random-uuid)
thread-id (random-uuid)
emitted (atom nil)
page (page/page-proxy plugin-id file-id page-id)
thread (comments/comment-thread-proxy
plugin-id
file-id
page-id
{:id thread-id :owner-id (random-uuid)})]
(mock/with-mocks
{r/check-permission (constantly true)
dc/delete-comment-thread-on-workspace
(mock/stub (fn [params callback]
(callback)
[:delete-thread params]))
st/emit! (mock/stub (fn [event] (reset! emitted event)))}
(fn [done']
(let [result (.removeCommentThread page thread)]
(t/is (instance? js/Promise result))
(t/is (= [:delete-thread {:id thread-id}] @emitted))
(done')))
done))))

View File

@ -11,9 +11,11 @@
[app.common.uuid :as uuid]
[app.main.data.workspace.reflow :as wrf]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.texts :as dwtxt]
[app.main.data.workspace.wasm-text :as dwwt]
[app.main.store :as st]
[app.plugins.api :as api]
[app.plugins.reflow :as pwrf]
[app.plugins.shape :as shape]
[app.util.object :as obj]
[beicon.v2.core :as rx]
@ -445,6 +447,24 @@
(set! st/stream (ptk/input-stream test-store))
test-store))
(t/deftest test-update-shapes-invokes-update-function-once
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)
{:renderer :svg})
_ (set! st/state store)
_ (set! st/stream (ptk/input-stream store))
^js ctx (api/create-context zero-id)
^js rect (.createRectangle ctx)
id (obj/get rect "$id")
calls (atom 0)]
(ptk/emit! store
(dwsh/update-shapes
[id]
(fn [shape]
(swap! calls inc)
(assoc shape :opacity 0.5))))
(t/is (= 1 @calls) "the update function ran once for the committed shape")
(t/is (= 0.5 (.-opacity rect)) "the single computed result was committed")))
(t/deftest test-wait-for-layout-update-no-pending
;; When nothing is pending the promise resolves immediately via the fast path
;; (the behavior-subject replays the empty map on subscribe).
@ -459,6 +479,209 @@
(t/is false (str "unexpected rejection: " err))
(done)))))))
(t/deftest test-create-text-bridges-dom-measurement
(t/async done
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)
{:renderer :svg})]
(set! st/state store)
(set! st/stream (ptk/input-stream store))
(ptk/emit! store (dwtxt/initialize-text-reflow))
(let [^js ctx (api/create-context zero-id)
^js text (.createText ctx "Measure me")
id (obj/get text "$id")]
(-> (.waitForLayoutUpdate ctx 20)
(.then #(t/is false "createText resolved before DOM measurement started"))
(.catch #(t/is true "createText stayed bridged to DOM measurement"))
(.then (fn []
(let [task (wrf/start! :text-measure [id])]
(wrf/finish! task))
(.waitForLayoutUpdate ctx 100)))
(.then #(t/is true "the bridge drained after measurement started"))
(.catch #(t/is false "the createText bridge did not drain"))
(.then (fn []
(ptk/emit! store (dwtxt/finalize-text-reflow))
(done))))))))
(t/deftest test-dom-position-data-stays-pending-until-commit
(t/async done
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)
{:renderer :svg})]
(set! st/state store)
(set! st/stream (ptk/input-stream store))
(ptk/emit! store (dwtxt/initialize-text-reflow))
(let [^js ctx (api/create-context zero-id)
^js text (.createText ctx "Position me")
id (obj/get text "$id")
task (wrf/start! :text-measure [id])
position-data
[{:x 10 :y 20 :width 30 :height 12}]]
(wrf/finish! task)
(-> (.waitForLayoutUpdate text 100)
(.then
(fn []
(ptk/emit! store (dwtxt/update-position-data id position-data))
(-> (.waitForLayoutUpdate text 20)
(.then (constantly false))
(.catch (constantly true)))))
(.then
(fn [timed-out?]
(t/is timed-out?
"position data stayed pending across its debounce")
(.waitForLayoutUpdate text 500)))
(.then
(fn []
(let [bounds (.-textBounds text)]
(t/is (= 30 (obj/get bounds "width"))
"the wait exposed the committed text bounds"))
(ptk/emit! store (dwtxt/finalize-text-reflow))
(done)))
(.catch
(fn [cause]
(ptk/emit! store (dwtxt/finalize-text-reflow))
(t/is false (str "position-data wait did not settle: " cause))
(done))))))))
(t/deftest test-buffered-text-update-bridges-dom-measurement
(t/async done
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)
{:renderer :svg})]
(set! st/state store)
(set! st/stream (ptk/input-stream store))
(ptk/emit! store (dwtxt/initialize-text-reflow))
(let [^js ctx (api/create-context zero-id)
^js text (.createText ctx "Before")
id (obj/get text "$id")
task (wrf/start! :text-measure [id])]
(wrf/finish! task)
(-> (.waitForLayoutUpdate text 100)
(.then
(fn []
(ptk/emit! store (dwsh/update-shapes-buffer-start))
(set! (.-characters text) "After")
(ptk/emit! store (dwsh/update-shapes-buffer-stop))
(.waitForLayoutUpdate text 20)))
(.then #(t/is false "buffered update resolved before DOM measurement"))
(.catch #(t/is true "buffered update stayed bridged to DOM measurement"))
(.then
(fn []
(let [task (wrf/start! :text-measure [id])]
(wrf/finish! task))
(.waitForLayoutUpdate text 100)))
(.then #(t/is true "the buffered update bridge drained"))
(.catch #(t/is false "the buffered update bridge did not drain"))
(.then
(fn []
(ptk/emit! store (dwtxt/finalize-text-reflow))
(done))))))))
(t/deftest test-wasm-grow-type-wait-observes-its-resize
(t/async done
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1))]
(set! st/state store)
(set! st/stream (ptk/input-stream store))
(ptk/emit! store (dwtxt/initialize-text-reflow))
(let [^js ctx (api/create-context zero-id)
^js text (.createText ctx "Resize after grow type")]
(-> (.waitForLayoutUpdate text 500)
(.then
(fn []
(set! (.-growType text) "fixed")
(.waitForLayoutUpdate text 500)))
(.then
(fn []
(t/is (= "fixed" (.-growType text))
"the grow-type bridge drained after its WASM resize")
(ptk/emit! store (dwtxt/finalize-text-reflow))
(done)))
(.catch
(fn [cause]
(ptk/emit! store (dwtxt/finalize-text-reflow))
(t/is false (str "grow-type wait did not settle: " cause))
(done))))))))
(t/deftest test-cloned-text-bridges-dom-measurement
(t/async done
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)
{:renderer :svg})]
(set! st/state store)
(set! st/stream (ptk/input-stream store))
(ptk/emit! store (dwtxt/initialize-text-reflow))
(let [^js ctx (api/create-context zero-id)
^js text (.createText ctx "Clone me")
id (obj/get text "$id")
task (wrf/start! :text-measure [id])]
(wrf/finish! task)
(-> (.waitForLayoutUpdate text 100)
(.then
(fn []
(let [^js clone (.clone text)
clone-id (obj/get clone "$id")]
(-> (.waitForLayoutUpdate clone 20)
(.then #(t/is false "clone resolved before DOM measurement"))
(.catch #(t/is true "clone stayed bridged to DOM measurement"))
(.then
(fn []
(let [task (wrf/start! :text-measure [clone-id])]
(wrf/finish! task))
(.waitForLayoutUpdate clone 100)))))))
(.then #(t/is true "the cloned text bridge drained"))
(.catch #(t/is false "the cloned text bridge did not drain"))
(.then
(fn []
(ptk/emit! store (dwtxt/finalize-text-reflow))
(done))))))))
(t/deftest test-fixed-text-resize-bridges-dom-measurement
(t/async done
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)
{:renderer :svg})]
(set! st/state store)
(set! st/stream (ptk/input-stream store))
(ptk/emit! store (dwtxt/initialize-text-reflow))
(let [^js ctx (api/create-context zero-id)
^js text (.createText ctx "Resize me")
id (obj/get text "$id")
task (wrf/start! :text-measure [id])]
(wrf/finish! task)
(-> (.waitForLayoutUpdate text 100)
(.then
(fn []
;; Finish the grow-type update before resizing.
(set! (.-growType text) "fixed")
(let [task (wrf/start! :text-measure [id])]
(wrf/finish! task))
(.waitForLayoutUpdate text 100)))
(.then
(fn []
(.resize text 240 80)
;; Turn only this short wait into a boolean.
(-> (.waitForLayoutUpdate text 20)
(.then (fn [] false))
(.catch (fn [_] true)))))
(.then
(fn [timed-out?]
(t/is timed-out?
"fixed text resize stayed bridged to DOM measurement")
(let [task (wrf/start! :text-measure [id])]
(wrf/finish! task))
(.waitForLayoutUpdate text 100)))
(.then
(fn []
(t/is true "the resize bridge drained after measurement")
;; Match the DOM renderer's 0.001 geometry tolerance.
(.resize text 240.0005 80)
(.waitForLayoutUpdate text 100)))
(.then
(fn []
(t/is true "a sub-tolerance resize opened no DOM bridge")
(ptk/emit! store (dwtxt/finalize-text-reflow))
(done)))
(.catch
(fn [cause]
(ptk/emit! store (dwtxt/finalize-text-reflow))
(t/is false (str "unexpected resize bridge rejection: " cause))
(done))))))))
(t/deftest test-wait-for-layout-update-pending
;; While a shape is pending the context promise stays unresolved; it resolves
;; once that shape is marked done.
@ -584,6 +807,54 @@
20))
20))))))
(t/deftest test-wait-for-layout-update-ancestor
;; A shape wait also covers layout on its parents.
(t/async done
(let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1))]
(set! st/state store)
(set! st/stream (ptk/input-stream store))
(let [^js ctx (api/create-context zero-id)
^js board (.createBoard ctx)
^js rect (.createRectangle ctx)]
(.appendChild board rect)
(let [board-id (obj/get board "$id")
task (wrf/start! :layout [board-id])
resolved (atom false)]
(-> (.waitForLayoutUpdate rect)
(.then (fn [] (reset! resolved true)))
(.catch (fn [err]
(t/is false (str "unexpected rejection: " err)))))
(js/setTimeout
(fn []
(t/is (false? @resolved) "child wait must block on a pending ancestor")
(wrf/finish! task)
(js/setTimeout
(fn []
(t/is (true? @resolved) "resolves once the ancestor drains")
(done))
20))
20))))))
(t/deftest test-shape-wait-observes-file-sync
(t/async done
(let [file (cthf/sample-file :file1 :page-label :page1)
store (ths/setup-store file)
_ (set! st/state store)
_ (set! st/stream (ptk/input-stream store))
^js ctx (api/create-context zero-id)
^js shape (.createRectangle ctx)
task (wrf/start! :sync-file [(:id file)])]
(-> (.waitForLayoutUpdate shape 20)
(.then #(t/is false "shape wait ignored its pending file sync"))
(.catch
(fn []
(t/is true "shape wait remained pending for its file sync")
(wrf/finish! task)
(.waitForLayoutUpdate shape 100)))
(.then #(t/is true "shape wait drained after the file sync"))
(.catch #(t/is false "shape wait did not drain its file sync"))
(.then (fn [] (done)))))))
(t/deftest test-wait-for-layout-update-invalid-timeout
;; A non-numeric or non-positive timeout is an invalid argument. The method
;; always hands back a promise and rejects it, whatever the plugin's
@ -603,6 +874,7 @@
(rejected? (.waitForLayoutUpdate ctx -5))
(rejected? (.waitForLayoutUpdate ctx js/NaN))
(rejected? (.waitForLayoutUpdate ctx js/Infinity))
(rejected? (.waitForLayoutUpdate ctx 2147483648))
(rejected? (.waitForLayoutUpdate shape "soon"))])
(.then (fn [results]
(t/is (every? true? (array-seq results))
@ -661,7 +933,7 @@
resolved (atom false)]
(ptk/emit! store (dwsh/update-shapes-buffer-start))
(ptk/emit! store (dwwt/resize-wasm-text-all [id]))
(-> (wrf/wait-for-layout-update [id] nil)
(-> (pwrf/wait-for-layout-update [id] nil)
(.then (fn [] (reset! resolved true)))
(.catch (fn [err]
(t/is false (str "unexpected rejection: " err)))))

View File

@ -23,63 +23,75 @@
:query-params {:team-id (str team-id)}})
(t/deftest sso-check-is-cached-for-five-minutes
(let [team-id (uuid/next)
match (workspace-match team-id)
now (atom (ct/inst "2026-08-11T10:00:00Z"))
rpc-calls (atom 0)
events (atom [])]
(with-redefs [cf/flags (conj cf/flags :admin-console)
ct/now (mock/stub (fn [] @now))
rp/cmd! (mock/stub
(fn [command params]
(t/is (= :check-nitrate-sso command))
(t/is (= team-id (:team-id params)))
(swap! rpc-calls inc)
(rx/of {:authorized true})))
st/emit! (mock/stub
(fn [& emitted]
(swap! events into emitted)))]
(#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace")
(reset! now (ct/plus @now #js {:minutes 4 :seconds 59}))
(#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace")
(t/is (= 1 @rpc-calls))
(t/is (= 2 (count @events))))))
(t/async done
(let [team-id (uuid/next)
match (workspace-match team-id)
now (atom (ct/inst "2026-08-11T10:00:00Z"))
rpc-calls (atom 0)
events (atom [])]
(mock/with-mocks
{cf/flags (conj cf/flags :admin-console)
ct/now (mock/stub (fn [] @now))
rp/cmd! (mock/stub
(fn [command params]
(t/is (= :check-nitrate-sso command))
(t/is (= team-id (:team-id params)))
(swap! rpc-calls inc)
(rx/of {:authorized true})))
st/emit! (mock/stub
(fn [& emitted]
(swap! events into emitted)))}
(fn [done']
(#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace")
(reset! now (ct/plus @now #js {:minutes 4 :seconds 59}))
(#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace")
(t/is (= 1 @rpc-calls))
(t/is (= 2 (count @events)))
(done'))
done))))
(t/deftest sso-check-is-refreshed-after-five-minutes
(let [team-id (uuid/next)
match (workspace-match team-id)
now (atom (ct/inst "2026-08-11T10:00:00Z"))
rpc-calls (atom 0)]
(with-redefs [cf/flags (conj cf/flags :admin-console)
ct/now (mock/stub (fn [] @now))
rp/cmd! (mock/stub
(fn [_ _]
(swap! rpc-calls inc)
(rx/of {:authorized true})))
st/emit! mock/noop]
(#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace")
(reset! now (ct/plus @now #js {:minutes 5}))
(#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace")
(t/is (= 2 @rpc-calls)))))
(t/async done
(let [team-id (uuid/next)
match (workspace-match team-id)
now (atom (ct/inst "2026-08-11T10:00:00Z"))
rpc-calls (atom 0)]
(mock/with-mocks
{cf/flags (conj cf/flags :admin-console)
ct/now (mock/stub (fn [] @now))
rp/cmd! (mock/stub
(fn [_ _]
(swap! rpc-calls inc)
(rx/of {:authorized true})))
st/emit! mock/noop}
(fn [done']
(#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace")
(reset! now (ct/plus @now #js {:minutes 5}))
(#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace")
(t/is (= 2 @rpc-calls))
(done'))
done))))
(t/deftest sso-redirect-result-is-not-cached
(let [team-id (uuid/next)
match (workspace-match team-id)
rpc-calls (atom 0)
events (atom [])]
(with-redefs [cf/flags (conj cf/flags :admin-console)
rp/cmd! (mock/stub
(fn [_ _]
(swap! rpc-calls inc)
(rx/of {:authorized false
:redirect-uri "https://idp.example.com/authorize"})))
st/emit! (mock/stub
(fn [& emitted]
(swap! events into emitted)))]
(#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace")
(#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace")
(t/is (= 2 @rpc-calls))
(t/is (= 2 (count @events))))))
(t/async done
(let [team-id (uuid/next)
match (workspace-match team-id)
rpc-calls (atom 0)
events (atom [])]
(mock/with-mocks
{cf/flags (conj cf/flags :admin-console)
rp/cmd! (mock/stub
(fn [_ _]
(swap! rpc-calls inc)
(rx/of {:authorized false
:redirect-uri "https://idp.example.com/authorize"})))
st/emit! (mock/stub
(fn [& emitted]
(swap! events into emitted)))}
(fn [done']
(#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace")
(#'routes/check-sso-and-navigate match true "https://penpot.example.com/#/workspace")
(t/is (= 2 @rpc-calls))
(t/is (= 2 (count @events)))
(done'))
done))))

View File

@ -4407,14 +4407,17 @@ msgid "nitrate.modal-success.title"
msgstr "Welcome to Enterprise!"
#: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:104
msgid "nitrate.code-activation.footer-before"
msgstr "Need a code? Download your "
msgid "nitrate.code-activation.footer-title"
msgstr "Need a code?"
msgid "nitrate.code-activation.footer-link"
msgstr "activation code request"
msgid "nitrate.code-activation.footer-download"
msgstr "Download request"
msgid "nitrate.code-activation.footer-after"
msgstr " and contact us:"
msgstr "Send the file to"
msgid "nitrate.code-activation.footer-before"
msgstr "and we will send you your code."
#: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:86
msgid "nitrate.code-activation.input-label"

View File

@ -4278,14 +4278,17 @@ msgid "nitrate.modal-success.title"
msgstr "¡Bienvenido a Enterprise!"
#: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:104
msgid "nitrate.code-activation.footer-before"
msgstr "¿Necesitas un código? Descarga tu "
msgid "nitrate.code-activation.footer-title"
msgstr "¿Necesitas un código?"
msgid "nitrate.code-activation.footer-link"
msgstr "solicitud de código de activación"
msgid "nitrate.code-activation.footer-download"
msgstr " Descargar solicitud"
msgid "nitrate.code-activation.footer-after"
msgstr " y contáctanos:"
msgstr "Mánda el fichero a"
msgid "nitrate.code-activation.footer-before"
msgstr "y te enviaremos tu código."
#: src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs:86
msgid "nitrate.code-activation.input-label"

View File

@ -1,6 +1,13 @@
import { expect, expectReject } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { Board, Font, Group, Shape, Text } from '@penpot/plugin-types';
import type {
Board,
Font,
Group,
Penpot,
Shape,
Text,
} from '@penpot/plugin-types';
import type { TestContext } from '../framework/types';
// waitForLayoutUpdate (context-level and per-shape).
@ -43,8 +50,17 @@ function byX(rects: [Shape, Shape]): { left: Shape; right: Shape } {
return a.x <= b.x ? { left: a, right: b } : { left: b, right: a };
}
/** Font ids already handed out by `unloadedFont`. */
const claimedFonts = new Set<string>();
/** Tracks fonts used by this test run. */
const claimedFontsByRun = new WeakMap<Penpot, Set<string>>();
function claimedFonts(ctx: TestContext): Set<string> {
let claimed = claimedFontsByRun.get(ctx.penpot);
if (!claimed) {
claimed = new Set<string>();
claimedFontsByRun.set(ctx.penpot, claimed);
}
return claimed;
}
/**
* Picks an unclaimed font differing from the text's current one, so assigning
@ -53,11 +69,12 @@ const claimedFonts = new Set<string>();
*/
function unloadedFont(ctx: TestContext, t: Text): Font {
const all = ctx.penpot.fonts.all;
const claimed = claimedFonts(ctx);
for (let i = all.length - 1; i >= 0; i--) {
const f = all[i];
if (f.fontId === t.fontId || f.variants.length === 0) continue;
if (claimedFonts.has(f.fontId)) continue;
claimedFonts.add(f.fontId);
if (claimed.has(f.fontId)) continue;
claimed.add(f.fontId);
return f;
}
throw new Error('no alternative font available');
@ -351,6 +368,89 @@ describe('WaitForLayoutUpdate', () => {
});
});
describe('Components', () => {
test('wait covers propagation from a component main to its copy', async (ctx) => {
const source = ctx.penpot.createRectangle();
ctx.board.appendChild(source);
const component = ctx.penpot.library.local.createComponent([source]);
const main = component.mainInstance() as Board;
const copy = component.instance() as Board;
ctx.board.appendChild(copy);
await ctx.penpot.waitForLayoutUpdate();
const mainChild = main.children[0];
const copyChild = copy.children[0];
mainChild.opacity = 0.37;
await ctx.penpot.waitForLayoutUpdate();
expect(copyChild.opacity).toBeCloseTo(0.37);
});
test('shape wait covers propagation from a component main to its copy', async (ctx) => {
const source = ctx.penpot.createRectangle();
ctx.board.appendChild(source);
const component = ctx.penpot.library.local.createComponent([source]);
const main = component.mainInstance() as Board;
const copy = component.instance() as Board;
ctx.board.appendChild(copy);
await ctx.penpot.waitForLayoutUpdate();
const mainChild = main.children[0];
const copyChild = copy.children[0];
mainChild.opacity = 0.63;
await copyChild.waitForLayoutUpdate();
expect(copyChild.opacity).toBeCloseTo(0.63);
});
test('wait covers layout triggered by component propagation', async (ctx) => {
const host = flexBoard(ctx);
const flex = host.addFlexLayout();
flex.dir = 'row';
flex.columnGap = 10;
const first = ctx.penpot.createRectangle();
first.resize(50, 50);
flex.appendChild(first);
const second = ctx.penpot.createRectangle();
second.resize(50, 50);
flex.appendChild(second);
const component = ctx.penpot.library.local.createComponent([host]);
const main = component.mainInstance() as Board;
const copy = component.instance() as Board;
ctx.board.appendChild(copy);
await ctx.penpot.waitForLayoutUpdate();
const { left: mainLeft } = byX([main.children[0], main.children[1]]);
mainLeft.resize(120, 50);
await ctx.penpot.waitForLayoutUpdate();
const { left: copyLeft, right: copyRight } = byX([
copy.children[0],
copy.children[1],
]);
expect(copyLeft.width).toBeCloseTo(120, 0);
expect(copyRight.x - copyLeft.x).toBeCloseTo(130, 0);
});
});
describe('Library assets', () => {
test('wait covers propagation from a local color to a referenced shape', async (ctx) => {
const color = ctx.penpot.library.local.createColor();
color.color = '#112233';
color.opacity = 1;
const rect = ctx.penpot.createRectangle();
rect.fills = [color.asFill()];
ctx.board.appendChild(rect);
await ctx.penpot.waitForLayoutUpdate();
color.color = '#aabbcc';
await ctx.penpot.waitForLayoutUpdate();
expect(rect.fills[0]?.fillColor).toBe('#aabbcc');
});
});
// A font applied to a group reaches its text descendants, so the work is
// pending on the children and never on the group itself. Skipped under a
// mocked backend for the same reason as the Text group: no fonts are served,

View File

@ -60,15 +60,41 @@ body {
background-color: var(--background-secondary);
}
.group-summary {
.group-header {
display: flex;
align-items: center;
gap: var(--spacing-8, 8px);
padding: var(--spacing-8, 8px);
}
/* Makes the full header row toggle the group. */
.group-toggle {
display: flex;
flex: 1;
align-items: center;
gap: var(--spacing-8, 8px);
min-width: 0;
margin: 0;
padding: 0;
border: none;
background: none;
color: inherit;
font: inherit;
text-align: start;
cursor: pointer;
user-select: none;
}
.group-chevron {
flex: 0 0 auto;
color: var(--foreground-secondary);
transition: transform 0.15s ease;
}
.group-toggle[aria-expanded='true'] .group-chevron {
transform: rotate(90deg);
}
.group-name {
color: var(--foreground-primary);
}
@ -144,6 +170,11 @@ body {
padding: 0;
}
/* Keeps hidden test lists out of the layout. */
.test-list[hidden] {
display: none;
}
.test-row {
display: grid;
grid-template-columns: 1fr auto auto;

View File

@ -128,6 +128,13 @@ function reloadIcon(): SVGSVGElement {
return svgIcon(['M13 8a5 5 0 1 1-1.46-3.54', 'M13 2.5v3h-3'], false);
}
/** Shows whether a group is expanded. */
function chevronIcon(): SVGSVGElement {
const icon = svgIcon(['M6 3.5 10.5 8 6 12.5'], false);
icon.classList.add('group-chevron');
return icon;
}
function render() {
root.replaceChildren(
renderHeader(),
@ -273,9 +280,12 @@ function renderRow(test: TestMeta): HTMLElement {
return row;
}
function renderGroupSummary(
/** Builds a group header with separate select, toggle, and run controls. */
function renderGroupHeader(
name: string,
groupTestList: TestMeta[],
panelId: string,
expanded: boolean,
): HTMLElement {
const statuses = groupTestList.map(
(t) => results.get(t.id)?.status ?? 'pending',
@ -291,12 +301,12 @@ function renderGroupSummary(
const groupCheckbox = el('input', {
type: 'checkbox',
className: 'checkbox-input',
title: `Select every test in "${name}"`,
ariaLabel: `Select every test in "${name}"`,
checked: selectedCount === total && total > 0,
disabled: running,
});
groupCheckbox.indeterminate = selectedCount > 0 && selectedCount < total;
// Keep the checkbox from toggling the <details> when clicked.
groupCheckbox.addEventListener('click', (e) => e.stopPropagation());
groupCheckbox.addEventListener('change', () => {
if (groupCheckbox.checked) ids.forEach((id) => selected.add(id));
else ids.forEach((id) => selected.delete(id));
@ -305,17 +315,14 @@ function renderGroupSummary(
const runButton = el('button', {
className: 'icon-button run-group',
type: 'button',
title: `Run "${name}"`,
ariaLabel: `Run "${name}"`,
disabled: running,
});
runButton.dataset.appearance = 'secondary';
runButton.append(playIcon());
runButton.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
run(ids);
});
runButton.addEventListener('click', () => run(ids));
const counts = el('span', { className: 'group-counts' }, [
el('span', { className: 'count-pass', textContent: `${passed}` }),
@ -327,14 +334,26 @@ function renderGroupSummary(
}),
]);
return el('summary', { className: 'group-summary' }, [
groupCheckbox,
const toggle = el('button', { className: 'group-toggle', type: 'button' }, [
chevronIcon(),
el('span', {
className: `status-dot dot-${aggregate}`,
title: statusLabel(aggregate),
}),
el('span', { className: 'group-name', textContent: name }),
counts,
]);
toggle.setAttribute('aria-expanded', String(expanded));
toggle.setAttribute('aria-controls', panelId);
toggle.addEventListener('click', () => {
if (expanded) expandedGroups.delete(name);
else expandedGroups.add(name);
render();
});
return el('div', { className: 'group-header' }, [
groupCheckbox,
toggle,
runButton,
]);
}
@ -342,25 +361,24 @@ function renderGroupSummary(
function renderList(): HTMLElement {
const container = el('div', { className: 'groups' });
for (const group of groupTests()) {
const details = el('details', { className: 'group' });
groupTests().forEach((group, index) => {
// Groups are collapsed by default; remember the ones the user expands.
details.open = expandedGroups.has(group.name);
details.addEventListener('toggle', () => {
if (details.open) expandedGroups.add(group.name);
else expandedGroups.delete(group.name);
});
const expanded = expandedGroups.has(group.name);
const panelId = `group-panel-${index}`;
details.append(renderGroupSummary(group.name, group.tests));
const list = el('ul', { className: 'test-list' });
const list = el('ul', { className: 'test-list', id: panelId });
list.hidden = !expanded;
for (const test of group.tests) {
list.append(renderRow(test));
}
details.append(list);
container.append(details);
}
container.append(
el('div', { className: 'group' }, [
renderGroupHeader(group.name, group.tests, panelId, expanded),
list,
]),
);
});
return container;
}

View File

@ -1353,12 +1353,13 @@ export interface Context {
/**
* This method returns a promise that will be resolved when all the
* pending layout updates have finished. If no layout work is pending
* the promise resolves immediately.
* pending layout updates have finished and the components have synchronized.
* If no layout work is pending the promise resolves immediately.
* @param timeout Maximum time to wait, in milliseconds. If the timeout
* elapses before the layout settles, the promise is rejected. Defaults to
* 30000; the promise never waits indefinitely.
* @return The promise to be resolved when the layout is updated
* @return The promise to be resolved when the layout is updated. It is
* rejected with an Error, both on timeout and on an invalid timeout value.
*/
waitForLayoutUpdate(timeout?: number): Promise<void>;
}
@ -4109,13 +4110,14 @@ export interface ShapeBase extends PluginData {
remove(): void;
/**
* This method returns a promise that will be resolved when the pending
* layout updates for this shape and its children have finished. If no layout
* work is pending for them the promise resolves immediately.
* This method returns a promise that will be resolved when all the
* pending layout updates have finished and the components have synchronized.
* If no layout work is pending the promise resolves immediately.
* @param timeout Maximum time to wait, in milliseconds. If the timeout
* elapses before the shape's layout settles, the promise is rejected.
* Defaults to 30000; the promise never waits indefinitely.
* @return The promise to be resolved when the shape's layout is updated
* @return The promise to be resolved when the shape's layout is updated. It
* is rejected with an Error, both on timeout and on an invalid timeout value.
*/
waitForLayoutUpdate(timeout?: number): Promise<void>;
}

View File

@ -5,8 +5,9 @@ gh.py — Multi-purpose CLI helper for penpot/penpot GitHub operations.
Uses GitHub GraphQL and REST APIs via the authenticated ``gh`` CLI.
Subcommands:
issues List issues in a milestone (or unassigned with milestone=none)
prs Fetch details for one or more PRs (by number or milestone)
issues List issues in a milestone (or unassigned with milestone=none)
prs Fetch details for one or more PRs (by number or milestone)
advisories List or inspect GitHub security advisories
Usage:
python3 scripts/gh.py issues <milestone-title> (default: state=closed)
@ -23,6 +24,9 @@ Usage:
cat prs.txt | python3 scripts/gh.py prs --stdin
python3 scripts/gh.py prs --milestone "2.16.0" (default: state=merged)
python3 scripts/gh.py prs --milestone "2.16.0" --state all
python3 scripts/gh.py advisories (list all advisories)
python3 scripts/gh.py advisories --severity critical (filter by severity)
python3 scripts/gh.py advisories GHSA-xvj6-fh9w-gjw7 (single advisory detail)
Prerequisites:
- gh CLI authenticated (gh auth status)
@ -63,6 +67,16 @@ def run_gh_graphql(query: str, variables: dict) -> Any:
return body["data"]
def run_gh_rest(path: str) -> Any:
"""Run a REST API call via ``gh api``."""
cmd = ["gh", "api", path]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"gh error: {result.stderr}", file=sys.stderr)
sys.exit(1)
return json.loads(result.stdout)
# ─────────────────────────────────────────────
# Shared: milestone lookup
# ─────────────────────────────────────────────
@ -581,6 +595,93 @@ def cmd_prs(args: argparse.Namespace) -> None:
print(json.dumps(all_results, indent=2))
# ─────────────────────────────────────────────
# Subcommand: advisories
# ─────────────────────────────────────────────
def fetch_advisories() -> list[dict]:
"""Fetch all security advisories for the repository via REST API."""
return run_gh_rest(f"repos/{REPO}/security-advisories")
def fetch_advisory(ghsa_id: str) -> dict:
"""Fetch a single security advisory by GHSA ID."""
return run_gh_rest(f"repos/{REPO}/security-advisories/{ghsa_id}")
def format_advisory_summary(adv: dict) -> dict:
"""Extract a summary view of an advisory."""
return {
"ghsa_id": adv["ghsa_id"],
"cve_id": adv.get("cve_id"),
"severity": adv.get("severity"),
"cvss_score": (adv.get("cvss") or {}).get("score"),
"state": adv.get("state"),
"summary": adv.get("summary"),
"cwes": [c["cwe_id"] for c in adv.get("cwes", [])],
"published_at": adv.get("published_at"),
"closed_at": adv.get("closed_at"),
"url": adv.get("html_url"),
}
def format_advisory_detail(adv: dict) -> dict:
"""Extract full detail view of an advisory."""
summary = format_advisory_summary(adv)
summary["description"] = adv.get("description")
summary["vulnerabilities"] = [
{
"package": v.get("package", {}).get("name"),
"vulnerable_version_range": v.get("vulnerable_version_range"),
"patched_versions": v.get("patched_versions"),
}
for v in adv.get("vulnerabilities", [])
]
summary["credits"] = [
{"login": c.get("user", {}).get("login"), "type": c.get("type")}
for c in adv.get("credits_detailed", [])
]
summary["created_at"] = adv.get("created_at")
summary["updated_at"] = adv.get("updated_at")
summary["withdrawn_at"] = adv.get("withdrawn_at")
return summary
def cmd_advisories(args: argparse.Namespace) -> None:
"""Handle the ``advisories`` subcommand."""
# ── Single advisory detail ──────────────────────────────
if args.ghsa_id:
ghsa_id = args.ghsa_id.upper()
if not ghsa_id.startswith("GHSA-"):
ghsa_id = f"GHSA-{ghsa_id}"
print(f"Fetching advisory {ghsa_id}...", file=sys.stderr)
adv = fetch_advisory(ghsa_id)
print(json.dumps(format_advisory_detail(adv), indent=2))
return
# ── List all advisories ─────────────────────────────────
print("Fetching security advisories...", file=sys.stderr)
advisories = fetch_advisories()
print(f"Fetched {len(advisories)} advisories", file=sys.stderr)
results = [format_advisory_summary(adv) for adv in advisories]
# Apply filters
if args.severity:
sev = args.severity.lower()
results = [r for r in results if (r.get("severity") or "").lower() == sev]
print(f"After severity filter ({sev}): {len(results)} advisories", file=sys.stderr)
if args.state:
st = args.state.lower()
results = [r for r in results if (r.get("state") or "").lower() == st]
print(f"After state filter ({st}): {len(results)} advisories", file=sys.stderr)
print(json.dumps(results, indent=2))
# ─────────────────────────────────────────────
# CLI entrypoint
# ─────────────────────────────────────────────
@ -645,6 +746,22 @@ def main() -> None:
)
p_prs.set_defaults(func=cmd_prs)
# --- advisories ---
p_adv = sub.add_parser("advisories", help="List or inspect GitHub security advisories")
p_adv.add_argument(
"ghsa_id", nargs="?",
help="GHSA ID to fetch (e.g. 'GHSA-xvj6-fh9w-gjw7'); omit to list all"
)
p_adv.add_argument(
"--severity", choices=["critical", "high", "medium", "low"],
help="Filter by severity level"
)
p_adv.add_argument(
"--state", choices=["triage", "draft", "published", "closed", "withdrawn"],
help="Filter by advisory state"
)
p_adv.set_defaults(func=cmd_advisories)
args = parser.parse_args()
args.func(args)