Compare commits

...

21 Commits

Author SHA1 Message Date
undergroundwires
704a3d0417 Fix card height inconsistency
TODO: Is CardLayout, card-layout, cardLayout etc., best names possiuble?

Fix card expansion panel heights not being equal. This is due to
limitations in CSS flex view.

Heights of all cards are `100%` which gives them uniform loook on same
row with all equal heights. However, their heights are set to `auto`
when of the cards are open. In that case, their sizes are no longer
equal in same row, cards with longer text/titles keeping more space.
This is because when a card is open, its expansion panel grows in its
own DOM element, increasing the height of the card.
at same height on each line, their heights change completely when one of

Heights gets fucked up when card is collapsing, this is big fix, fix this.

Supporting changes:

- Move card expander to its own component `CardExpansionPanel`.
- Introduce `UseCardLayout` hook to do calculations instead of CSS to
  circumvent limitations in CSS flex view.
2024-05-28 16:20:20 +02:00
undergroundwires
22d6c7991e ci/cd: centralize and bump artifact uploads
- Upgrade `actions/upload-artifact` to `v4` to address deprecation
  warnings related to Node.js 16, improving compatibility with GitHub
  runners. This resolves the following warning from the runners:
  > Node.js 16 actions are deprecated. Please update the following actions
  > to use Node.js 20: actions/upload-artifact@v3.
- Centralize the use of the `upload-artifact` action through a new
  custom action, improving maintainability and consistency across
  workflows.
2024-05-28 12:53:45 +02:00
undergroundwires-bot
795b7f0321 ⬆️ bump everywhere to 0.13.4 2024-05-27 13:45:04 +00:00
undergroundwires
9e34e64449 win, mac, linux: fix typos and dead URLs #367
- Fix multiple spelling errors in various scripts.
- Fix dead URLs with archived versions.
- Fix incorrect registry keys previously introduced in commit
  cec0b4b4f6.

Co-authored-by: RainRat <rainrat78@yahoo.ca>
2024-05-27 14:54:17 +02:00
undergroundwires
ce4cfdd169 win: add script to disable Recall feature 2024-05-27 10:44:11 +02:00
undergroundwires
12b1f183f7 win: document disabling firewall #115 #152 #364
This commit updates documentation to clarify the impacts of disabling
firewall services, specifically how they affect Windows Sandbox, Docker
and WSL.

This update responds to user feedback from issues #115, #152, #364. The
documentation now guides users more clearly on the consequences of their
actions, potentially preventing unintended service disruptions.

Changes include:

- Expand the caution notes to explicitly mention the impact on
  virtualization and isolation features like Windows Sandbox, Docker and
  WSL.
- Expand script titles to briefly mention affects on these features.
- Expand documentation to suggest system restart.
- Add an informative message to restart the computer in terminal outputs
  after service changes to ensure the settings are applied.
2024-05-26 13:42:25 +02:00
undergroundwires
4212c7b9e0 Improve context for errors thrown by compiler
This commit introduces a custom error object to provide additional
context for errors throwing during parsing and compiling operations,
improving troubleshooting.

By integrating error context handling, the error messages become more
informative and user-friendly, providing sequence of trace with context
to aid in troubleshooting.

Changes include:

- Introduce custom error object that extends errors with contextual
  information. This replaces previous usages of `AggregateError` which
  is not displayed well by browsers when logged.
- Improve parsing functions to encapsulate error context with more
  details.
- Increase unit test coverage and refactor the related code to be more
  testable.
2024-05-25 13:55:30 +02:00
undergroundwires
7794846185 win: discourage blocking app access #121 #339 #350
This commit adjusts the recommendation level for scripts that disable
UWP app access to accommodate user issues #121, #339, #350. It also
extends their documentation to reflect the new changes and with
cautions.

Changes:

- Add caution text for all scripts about potential impacts.
- Move disabling app access to notifications from 'Standard' to
  'Strict'. This addresses #121 and #339, where users report lack of
  notification as unintended side-effects.
- Move disabling app access to phone calls from 'Standard' to 'Strict'.
  This addresses #350 where its effect on the Phone Link app was
  reported as an unintended side-effect.
2024-05-24 10:45:23 +02:00
undergroundwires
150e067039 win: improve printing removal /w Print Queue #279
- Consolidate removal of printing UIs under the same category.
- Improve documentation for printing app removal scripts.
- Add removal of previously unlisted 'Print Queue' app, #279.
- Combine removal of `Microsoft.Print3D` and `Windows.Print3D`
  into a single script.
- Highlight the importance of removing 'Print 3D' app due to
  security risks and recommend it on 'Standard'.
2024-05-23 09:27:14 +02:00
undergroundwires
f347fde0c8 win: document and discourage RSA key script #363
This commit improves the documentation of RSA key handling script and
changes its recommendation level to address potential issues with
Hyper-V (as reported in #363).

Changes:

- Add documentation to describe potential disruptions caused by stronger
  RSA key requirements.
- Move RSA key script from 'Standard' to 'Strict' due to its impact on
  Hyper-V VMs.
- Use bullet points for easier expansion in cautions of secret key
  hardening scripts.
2024-05-22 08:10:37 +02:00
undergroundwires
ff3d5c4841 win: improve app access disabling and docs #138
This commit improves disabling app access by correcting minor issues,
adding missing access control configurations and adding more
documentation to increase maintainability and user understanding of
privacy settings, resolving #138.

- Introduce shared functions to streamline modifications of app access,
  improving maintainability and simplifying the codebase.
- Move disabling app access to first position in the category.
- Improve code comments for better clarity on generated outputs.
- Resolve error display in revert codes due to incorrect use of `reg
  delete` commands.
- Fix disabling app access to trusted devices disables access to account
  information, name and picture on older versions of Windows.
- Add missing privacy settings and configurations.
- Add more documentation to scripts
- Rename script names for consistency and clarity.
- Move disabling access to SMS/MMS to phone access disablement category.
- Set empty `REG_MULTI_SZ` values to null for GPO access settings to
  maintain registry integrity.
2024-05-21 13:02:48 +02:00
undergroundwires
292362135d Centralize and optimize ResizeObserver usage
This commit addresses failures in end-to-end tests that occurred due to
`ResizeObserver` loop limit exceptions.

These errors were triggered by Vue dependency upgrades in the commit
aae5434451.
The errors had the following message:
> `ResizeObserver loop completed with undelivered notifications`

This error happens when there are too many observations and the observer
is not able to deliver all observations within a single animation frame.
See: WICG/resize-observer#38

his commit resolves the issue by controlling how many observations are
delivered per animation frame and limiting it to only one.

It improves performance by reducing layout trashing, improving frame
rates, and managing resources more effectively.

Changes:

- Introduce an animation frame control to manage observations more
  efficiently.
- Centralized `ResizeObserver` management within the `UseResizeObserver`
  hook to improve consistency and reuse across the application.
2024-05-20 10:36:49 +02:00
undergroundwires
aae5434451 Bump Vue to latest and fix universal selector CSS
This commit updates the Vue package from v3.4.21 to v3.4.27.

This version change addressed styling issues introduced by changes in
CSS universal selector handling in Vue 3.4.22.
The change that has caused this:
- vuejs/core#10551
- vuejs/core#10548
- vuejs/core@54a6afa75a

This commit fixes two main issues that this has led to:

1. Universal CSS selector causing 'Revert' buttons to stretch and
   truncate incorrectly.
   This is fixed by modifying selectors to apply styles more
   specifically, maintaining correct display of toggle buttons.
2. Universal `*` selector that's used to understand parent HTML
   structure causing information tooltip icons to be misaligned.
   This is fixed by replacing `*` with a new `InfoTooltipWrapper`
   component, which manages layout concerns more explicitly and
   maintainably.
2024-05-19 15:02:38 +02:00
undergroundwires
2390530d92 ci/cd: fix quality checks not running on all OSes
Previously, quality checks were mistakenly configured to run only on
Ubuntu.

This commit modifies the CI/CD workflow to use the matrix strategy,
allowing the quality checks to be executed on macOS, Ubuntu and Windows.

Additionally, this update resolves the `MD034/no-bare-urls Bare URL
used` linting error that surfaced when testing on Windows.
2024-05-18 13:14:05 +02:00
undergroundwires
9ab3ff75b0 Migrate to GitHub issue forms
This commit transitions from HTML-based issue templates to GitHub issue
forms, enhancing user experience by preventing accidental submissions
with comment-like metadata. This change makes submitting issues more
intuitive and reduces the chances of user errors (such as #355).

Key change include:

- Use a friendlier tone in the templates.
- Detail examples and descriptions to guide users more effectively.
- Rename templates for improved clarity and easy navigation.
- Add "a note from the maintainer".
- Include a direct link for donations to support the project.
2024-05-17 17:52:14 +02:00
undergroundwires
d25c4e8c81 Add support for macOS universal binary #348, #362
This commit introduces a universal binary format in the distributed MDG
files for macOS, improving support for both Apple Silicon (ARM) and
Intel (x64) architectures.

It uses `electron-builder` to package both architectures into a single
executable, ensuring the application can natively on any macOS hardware
without depending on the GitHub runners' architecture. It fixes the
issue related to prior releases that supported only the architecture of
the build environment itself, which is subject to change.

Changes:

- Update DMG distribution to include both ARM64 and x64 architectures.
- Enhance system requirements documentation to reflect support for both
  architectures.
- Modify CI/CD workflows to check desktop runtime errors for both ARM64
  and x64 versions on macOS.

Resolves:

- Issue #348: Initial request for Apple Silicon support.
- Issue #362: Correction of distribution limited to ARM64 in release
  0.13.3.

`electron-builder` support:
- electron-userland/electron-builder#5475
- electron-userland/electron-builder#5689
- electron-userland/electron-builder#5426
2024-05-16 10:53:55 +02:00
undergroundwires
4a7efa27c8 Fix e2e test failing on Windows
The recent addition of revert logic in first visible card on Windows
(Privacy Cleanup) in cec0b4b, introduced an issue where end-to-end (e2e)
started failing due to the handling of hidden elements.

This commit improves the test to correctly handle the hidden card
scenario, explicitly filtering visible elements to ensure that only
visible elements are handled.
2024-05-15 09:03:18 +02:00
undergroundwires
cec0b4b4f6 win: standardize registry edit + delete on revert
This commit standardizes the management of registry keys and their
corresponding revert on delete action across all scripts using
`SetRegistryValue` function.

It improves script reliability, addresses previous errors, and corrects
the revert actions to match the default OS state when not explicitly set
by the OS.

Key changes:

- Use SetRegistryValue for uniformity.
- Remove error messages for non-existent registry keys, recognizing them
  as expected states rather than errors.
- Add missing revert actions to scripts where they were absent.
- Correct the revert logic in existing scripts to match the default OS
  configurations, particularly when the OS does not set a default value.
- Update documentation about default OS state for the related scripts.

This change improves maintainability by centralizing and standardizing
registry interactions, reducing the risk of errors and inconsistencies
in script behaviors.
2024-05-14 12:41:20 +02:00
undergroundwires
a1922c50c1 ci/cd: fix recent Docker build failures on macOS
The GitHub workflow for testing Docker builds on macOS was consistently
failing. This commit downgrades the macOS version used for Docker tests
to `macos-13`, which is the latest Intel-based macOS runner, instead of
the ARM-based `macos-14` which `macos-latest` points to.

This change is necessary because the hypervisor framework required for
Docker is not supported on the ARM-based macOS runners provided by
GitHub. This issue was causing failures when attempting to run Colima
with QEMU using `-accel hvf`, which is unsupported on these runners.
Switching to an Intel-based runner resolves this issue.

Related issues:
- actions/runner-images#9460
- actions/runner-images#9741
- abiosoft/colima#1023
2024-05-13 11:09:58 +02:00
undergroundwires
870120bc13 Add specific empty function name compiler error
This commit adds checks to rjeect functions with empty or whitespace
names. The compiler throws a specific errror when it encounters a
function data object lacking a proper name.

This provides early detection and clear feedback on invalid function
definitions, helping in faster debugging and ensuring script integrity
in the compilation process.

The enhancement aims to provide early detection and clear feedback
on invalid function definitions, aiding in faster debugging and
ensuring script integrity in the compilation process.

when it encounters a function data object lacking a proper name.
It covers scenarios where the function name might be an empty string,
undefined, or solely consist of whitespace.
2024-05-12 12:32:42 +02:00
undergroundwires-bot
f38cf73485 ⬆️ bump everywhere to 0.13.3 2024-05-11 09:58:21 +00:00
126 changed files with 9340 additions and 3509 deletions

View File

@@ -1,57 +0,0 @@
---
name: Bug report (script bug or unexpected script behavior)
about: Create a bug report for generated scripts to help privacy.sexy improve
labels: bug
title: '[BUG]: '
---
<!--
Thank you for reporting an issue with generated script(s).
Please fill in as much of the template below as you're able.
As a small open source project with small community, it can sometimes take a long time for issues to be addressed so please be patient.
-->
### Description
<!--
A clear and concise description of what the bug is.
-->
### OS
<!--
Which OS are you using? What version of OS you were using?
On Windows: Open "Start button" > "Settings" > "System" > "About".
On macOS: Open "Apple menu (top left corner)" > "About This Mac".
On Linux: Open terminal > type: lsb_release -a > copy paste the result.
-->
### Reproduction steps
<!--
How can the bug be recreated?
It's the most important information in the bug report. Bugs that cannot be reproduced cannot be fixed and verified.
E.g.
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
-->
### Scripts
<!--
If applicable, please attach the generated privacy.sexy file instead of copy pasting which becomes too long.
-->
### Screenshots
<!--
If applicable, add screenshots to help explain your problem.
-->
### Additional information
<!--
If applicable, add any other context about the problem here.
-->

View File

@@ -0,0 +1,114 @@
name: "Bug Report: Script Issues"
description: 🐛 Report issues with generated scripts to enhance privacy.sexy
labels: [ 'bug' ]
title: '[Bug]: '
body:
-
type: markdown
attributes:
value: |-
Thank you for contributing to privacy.sexy and guiding our direction! 🌟
Please complete as much of the form below as possible.
Your feedback is valuable, even if you can't provide all details.
-
type: textarea
attributes:
label: Description
description: A clear and concise description of what the bug is.
placeholder: >-
For example: "After running the cleanup script, music playback stopped functioning."
validations:
required: true
-
type: textarea
attributes:
label: How can the bug be recreated?
description: |-
This is the most important information in the bug report.
Bugs that cannot be reproduced cannot be fixed or verified.
placeholder: |-
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
-
type: textarea
attributes:
label: Operating system
description: |-
Please specify your operating system and its version.
- On Windows: Open "Start button" > "Settings" > "System" > "About".
- On macOS: Open "Apple menu (top left corner)" > "About This Mac".
- On Linux: Open terminal > type: lsb_release -a > copy paste the result.
placeholder: >-
For example: "Windows 11 Pro 22H3"
validations:
required: false
-
type: textarea
attributes:
label: Script file
description: |-
If applicable, share the generated privacy.sexy file.
GitHub may restrict script file attachments.
Upload your script file to a service like [GitHub Gist](https://gist.github.com/) and share the link here.
If you used the desktop version to run the script, it is already stored on your system.
See the [documentation to locate it](https://github.com/undergroundwires/privacy.sexy/blob/master/docs/desktop/desktop-vs-web-features.md#secure-script-executionstorage).
> **💡 Tip:** You can attach script files by dragging them into this area.
placeholder: |-
Attach the script, or post GitHub Gist link.
For example: https://gist.github.com/privacysexy-forks/6d85ad8ca27acc8c6a5417d4af28c9b6.
validations:
required: false
-
type: textarea
attributes:
label: Screenshots
description: |-
If applicable, add screenshots to help explain your problem.
> **💡 Tip:** You can attach screenshots by clicking this area to highlight it and then pasting them or dragging files in.
placeholder: Attach screenshots here or link to image hosting.
validations:
required: false
-
type: textarea
attributes:
label: Additional information
description: |-
If applicable, add any other context about the problem here.
Helpful information includes:
- Application logs (desktop version only), see: [how to find application logs](https://github.com/undergroundwires/privacy.sexy/blob/master/docs/desktop/desktop-vs-web-features.md#logging).
- Terminal output
- Proposed solutions
- Other related context such as related issues, software behavior, etc.
> **💡 Tip:** You can attach log files by dragging them into this area.
placeholder: >-
For example: "Here are the logs I get from the privacy.sexy 0.13.2 desktop application: ..."
validations:
required: false
-
type: markdown
attributes:
value: |-
---
**✉️ A friendly note from the maintainer:**
> [!NOTE]
> We are a small open-source project with a small community.
> It can sometimes take a long time for issues to be addressed, so please be patient.
> Consider [donating](https://undergroundwires.dev/donate) to keep privacy.sexy alive and improve support ❤️.
> But your issue will eventually get attention regardless.
> <p align="right">@undergroundwires</p>
---

View File

@@ -0,0 +1,104 @@
name: "Bug Report: General"
description: 🐛 Report general issues to enhance privacy.sexy
labels: [ 'bug' ]
title: '[Bug]: '
body:
-
type: markdown
attributes:
value: |-
Thank you for contributing to privacy.sexy and guiding our direction! 🌟
Please complete as much of the form below as possible.
Your feedback is valuable, even if you can't provide all details.
-
type: textarea
attributes:
label: Description
description: Provide a clear and concise description of the issue.
placeholder: >-
For example: "I cannot select any scripts."
validations:
required: true
-
type: textarea
attributes:
label: Reproduction steps
description: |-
This is the most important information in the bug report.
Bugs that cannot be reproduced cannot be fixed or verified.
placeholder: |-
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
-
type: textarea
attributes:
label: Expected behavior
description: Describe what you expected to happen when the error occurred.
placeholder: >-
For example: "I expected the settings menu to open smoothly without crashing.".
validations:
required: true
-
type: textarea
attributes:
label: Screenshots
description: |-
If applicable, add screenshots to help explain your problem.
> **💡 Tip:** You can attach screenshots by clicking this area to highlight it and then pasting them or dragging files in.
placeholder: >-
Attach screenshots here or link to image hosting.
validations:
required: false
-
type: textarea
attributes:
label: privacy.sexy environment details
description: |-
If applicable, mention how you were using privacy.sexy when the bug occurred:
- Web (on which operating system and browser?)
- Or desktop (Windows, macOS, or Linux?)
placeholder: >-
For example: "The web version on Edge browser on Windows 11 23H2."
validations:
required: false
-
type: textarea
attributes:
label: Additional context
description: |-
If applicable, add any other context about the problem here.
Helpful information includes:
- Application logs (desktop version only), see: [how to find application logs](https://github.com/undergroundwires/privacy.sexy/blob/master/docs/desktop/desktop-vs-web-features.md#logging).
- Terminal output
- Proposed solutions
- Other related context such as related issues, software behavior, etc.
> **💡 Tip:** You can attach log files by dragging them into this area.
placeholder: >-
For example: "Here are the logs I get from the privacy.sexy 0.13.2 desktop application: ..."
validations:
required: false
-
type: markdown
attributes:
value: |-
---
**✉️ A friendly note from the maintainer:**
> [!NOTE]
> We are a small open-source project with a small community.
> It can sometimes take a long time for issues to be addressed, so please be patient.
> Consider [donating](https://undergroundwires.dev/donate) to keep privacy.sexy alive and improve support ❤️.
> But your issue will eventually get attention regardless.
> <p align="right">@undergroundwires</p>
---

View File

@@ -1,55 +0,0 @@
---
name: Bug report (unrelated to generated scripts)
about: Create a bug report that's not related to generated scripts to help privacy.sexy improve
labels: bug
title: '[BUG]: '
---
<!--
Thank you for reporting an issue.
Please fill in as much of the template below as you're able.
As a small open source project with small community, it can sometimes take a long time for issues to be addressed so please be patient.
-->
### Description
<!--
A clear and concise description of what the bug is.
-->
### Reproduction steps
<!--
It's the most important information in the bug report. Bugs that cannot be reproduced cannot be fixed and verified.
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
-->
### Expected behavior
<!--
A clear and concise description of what you expected to happen.
-->
### Screenshots
<!--
If applicable, add screenshots to help explain your problem.
-->
### Distribution
<!--
If applicable, mention how you were using privacy.sexy when the bug was encountered:
- Web (on Desktop or mobile?)
- Or desktop (Windows, macOS or Linux?)
-->
### Additional context
<!--
If applicable, add any other context about the problem here.
-->

View File

@@ -1,36 +0,0 @@
---
name: Feature request
about: Suggest an idea for privacy.sexy
labels: enhancement
---
<!--
Thank you for suggesting an idea to improve privacy better 🤗.
Please fill in as much of the template below as you're able.
-->
### Problem description
<!--
What are we trying to solve?
Please add a clear and concise description of the problem you are seeking to solve with this feature request.
E.g. I'm always frustrated when [...]
-->
### Proposed solution
<!--
Describe the solution you'd like in a clear and concise manner.
-->
### Alternatives considered
<!--
A clear and concise description of any alternative solutions or features you've considered.
-->
### Additional information
<!--
If applicable, add any other context or screenshots about the feature request here.
-->

View File

@@ -0,0 +1,73 @@
name: "Suggestion: Feature"
description: 💡 Suggest new ideas to enhance privacy.sexy
labels: [ 'enhancement' ]
title: '[Feature]: '
body:
-
type: markdown
attributes:
value: |-
Thank you for contributing to privacy.sexy and guiding our direction! 🌟
Please complete as much of the form below as possible.
Your feedback is valuable, even if you can't provide all details.
-
type: textarea
attributes:
label: Problem statement
description: |-
What are we trying to solve?
Please add a clear and concise description of the problem you are seeking to solve with this feature request.
placeholder: >-
For example: "Every time I use the app, I struggle with..."
validations:
required: true
-
type: textarea
attributes:
label: Proposed solution
description: |-
Describe the solution you'd like in a clear and concise manner.
placeholder: >-
For example: "It would be great if the app could..."
validations:
required: true
-
type: textarea
attributes:
label: Alternatives considered
description: |-
Have you considered any alternative solutions or features?
Different perspectives can inspire new ideas.
placeholder: >-
For example: "We could also solve it by...".
validations:
required: false
-
type: textarea
attributes:
label: Additional information
description: |-
If applicable, add any other context or screenshots about the feature request here.
> **💡 Tip:** You can attach files or screenshots by dragging them into this area.
placeholder: >-
For example: "Challenges can be ..., but I'm unsure about ..., here is some documentation about it: ..."
validations:
required: false
-
type: markdown
attributes:
value: |-
---
**✉️ A friendly note from the maintainer:**
> [!NOTE]
> We are a small open-source project with a small community.
> It can sometimes take a long time for issues to be addressed, so please be patient.
> Consider [donating](https://undergroundwires.dev/donate) to keep privacy.sexy alive and improve support ❤️.
> But your issue will eventually get attention regardless.
> <p align="right">@undergroundwires</p>
---

View File

@@ -1,60 +0,0 @@
---
name: New script suggestion
about: Suggest a new script for privacy.sexy
labels: enhancement
---
<!--
Thank you for contributing to privacy.sexy! 🌟
For guidance, see our script guidelines: https://github.com/undergroundwires/privacy.sexy/blob/master/docs/script-guidelines.md.
Consider submitting a PR for faster implementation: https://github.com/undergroundwires/privacy.sexy/blob/master/CONTRIBUTING.md#extend-scripts.
-->
### Operating system
<!--
Specify the OS: Windows, macOS, or Linux.
-->
### Name
<!--
Suggest a name for the script.
Naming conventions: https://github.com/undergroundwires/privacy.sexy/blob/master/docs/script-guidelines.md#name.
-->
### Code
<!--
Provide or explain the code to execute when the script runs.
Code guidelines: https://github.com/undergroundwires/privacy.sexy/blob/master/docs/script-guidelines.md#code.
-->
### Revert code
<!--
Include code to revert changes to the default state.
Leave blank for non-reversible scripts.
-->
### Category
<!--
Suggest a category for the script.
If unsure, leave blank for maintainers to decide.
-->
### Recommendation level
<!--
Suggest a recommendation level: STANDARD (non-breaking), STRICT (limits functionality), or NONE (for advanced users).
If unsure, leave blank for maintainers to decide.
-->
### Documentation/References
<!--
Provide any relevant documentation or references.
Prefer high-quality sources such as vendor documentation.
Documentation guidelines: https://github.com/undergroundwires/privacy.sexy/blob/master/docs/script-guidelines.md#documentation.
-->

View File

@@ -0,0 +1,133 @@
name: "Suggestion: New Script"
description: 💡 Suggest new scripts to enhance privacy.sexy
labels: [ 'enhancement' ]
title: '[New script]: '
body:
-
type: markdown
attributes:
value: |-
Thank you for contributing to privacy.sexy and guiding our direction! 🌟
Please complete as much of the form below as possible.
Your feedback is valuable, even if you can't provide all details.
For guidance, see our [script guidelines](https://github.com/undergroundwires/privacy.sexy/blob/master/docs/script-guidelines.md).
Consider submitting a PR to get your script added more quickly: (see [CONTRIBUTING.md](https://github.com/undergroundwires/privacy.sexy/blob/master/CONTRIBUTING.md#extend-scripts))
-
type: dropdown
attributes:
label: Operating system
description: Which operating system will the new script configure?
options:
- macOS
- Windows
- Linux
- All of them
validations:
required: false
-
type: textarea
attributes:
label: Name of the script
description: |-
Suggest a name for the script that clearly describes its function.
See [script naming conventions](https://github.com/undergroundwires/privacy.sexy/blob/master/docs/script-guidelines.md#name) for best practices.
placeholder: E.g, "Disable error data submission"
validations:
required: true
-
type: textarea
attributes:
label: Documentation/References
description: |-
Provide any relevant documentation or references.
Prefer high-quality sources such as vendor documentation.
See [documentation guidelines](https://github.com/undergroundwires/privacy.sexy/blob/master/docs/script-guidelines.md#documentation) for best practices.
placeholder: >-
For example: "This script will disable the error data submission, see https://microsoft.com/...".
validations:
required: true
-
type: textarea
attributes:
label: Code
description: |-
If possible, provide or explain the code that the script should execute.
See [script code guidelines](https://github.com/undergroundwires/privacy.sexy/blob/master/docs/script-guidelines.md#code).
placeholder: |-
For example: "Set registry key like this `reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\DataCollection" /v "AllowTelemetry" /t "REG_DWORD" /d "1"`".
validations:
required: false
-
type: textarea
attributes:
label: Revert code
description: |-
If applicable, provide revert code to restore the changes made by the script.
The revert code restores changes to their default state before script execution.
Leave blank for non-reversible scripts.
placeholder: |-
For example: "Revert to operating system default like this `reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\DataCollection" /v "AllowTelemetry" /t "REG_DWORD" /d "0"`".
validations:
required: false
-
type: textarea
attributes:
label: Suggested category
description: |-
Suggest a category for the script.
If unsure, leave blank for maintainers to decide.
placeholder: >-
For example: "Privacy Cleanup > Clear system logs"
-
type: dropdown
attributes:
label: Recommendation level
description: |-
Suggest a recommendation level for the script:
- **Standard**: Recommended for most users without side-effects.
- **Strict**: Provides improved privacy at the cost of some functionality.
- **None**: For advanced users or specific needs.
If unsure, leave blank for maintainers to decide.
options:
- Standard
- Strict
- None (do not recommend)
validations:
required: false
-
type: textarea
attributes:
label: Additional information
description: |-
If applicable, add any other context or screenshots about the script request here.
> **💡 Tip:** You can attach additional documents or screenshots by dragging them into this area or pasting directly.
placeholder: >-
For example: "Challenges can be ..., but I am unsure about ..."
validations:
required: false
-
type: markdown
attributes:
value: |-
---
**✉️ A friendly note from the maintainer:**
> [!NOTE]
> We are a small open-source project with a small community.
> It can sometimes take a long time for issues to be addressed, so please be patient.
> Consider [donating](https://undergroundwires.dev/donate) to keep privacy.sexy alive and improve support ❤️.
> But your issue will eventually get attention regardless.
> <p align="right">@undergroundwires</p>
---

View File

@@ -1 +1,7 @@
blank_issues_enabled: true
# This file must be named `config.yml`. GitHub does not recognize the file if it is named `config.yaml`.
blank_issues_enabled: true
contact_links:
- name: Donate
url: https://undergroundwires.dev/donate/
about: ❤️ Donate to support the free software you love to keep it alive.
# A separate link for reporting vulnerabilities is not included here because GitHub generates it automatically.

View File

@@ -0,0 +1,15 @@
inputs:
name:
required: true
path:
required: true
runs:
using: composite
steps:
-
name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.name }}
path: ${{ inputs.path }}

View File

@@ -72,16 +72,19 @@ jobs:
build-docker:
strategy:
matrix:
os: [ macos, ubuntu ] # Windows runners do not support Linux containers
os:
- macos-13 # Downgraded due to lack of nested virtualization support in ARM-based runners (See: actions/runner-images#9460, actions/runner-images#9741, abiosoft/colima#1023)
- ubuntu-latest
# - windows-latest # Windows runners do not support Linux containers
fail-fast: false # Allows to see results from other combinations
runs-on: ${{ matrix.os }}-latest
runs-on: ${{ matrix.os }}
steps:
-
name: Checkout
uses: actions/checkout@v4
-
name: Install Docker on macOS
if: matrix.os == 'macos' # macOS runner is missing Docker
if: contains(matrix.os, 'macos') # macOS runner is missing Docker
run: |-
# Install Docker
brew install docker

View File

@@ -9,9 +9,13 @@ jobs:
run-check:
strategy:
matrix:
os: [ macos, ubuntu, windows ]
os:
- macos-latest # Apple silicon (ARM64)
- macos-13 # Intel-based (x86-64)
- ubuntu-latest
- windows-latest
fail-fast: false # Allows to see results from other combinations
runs-on: ${{ matrix.os }}-latest
runs-on: ${{ matrix.os }}
steps:
-
name: Checkout
@@ -24,7 +28,7 @@ jobs:
uses: ./.github/actions/npm-install-dependencies
-
name: Configure Ubuntu
if: matrix.os == 'ubuntu'
if: contains(matrix.os, 'ubuntu') # macOS runner is missing Docker
shell: bash
run: |-
sudo apt update
@@ -66,7 +70,7 @@ jobs:
-
name: Upload screenshot
if: always() # Run even if previous step fails
uses: actions/upload-artifact@v3
uses: ./.github/actions/upload-artifact
with:
name: screenshot-${{ matrix.os }}
path: screenshot.png

View File

@@ -4,7 +4,7 @@ on: [ push, pull_request ]
jobs:
lint:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}-latest
strategy:
matrix:
lint-command:

View File

@@ -51,14 +51,14 @@ jobs:
-
name: Upload screenshots
if: failure() # Run only if previous steps fail because screenshots will be generated only if E2E test failed
uses: actions/upload-artifact@v3
uses: ./.github/actions/upload-artifact
with:
name: e2e-screenshots-${{ matrix.os }}
path: ${{ steps.artifacts.outputs.SCREENSHOTS_DIR }}
-
name: Upload videos
if: always() # Run even if previous steps fail because test run video is always captured
uses: actions/upload-artifact@v3
uses: ./.github/actions/upload-artifact
with:
name: e2e-videos-${{ matrix.os }}
path: ${{ steps.artifacts.outputs.VIDEOS_DIR }}

View File

@@ -1,5 +1,54 @@
# Changelog
## 0.13.4 (2024-05-27)
* Add specific empty function name compiler error | [870120b](https://github.com/undergroundwires/privacy.sexy/commit/870120bc13909a3681e0f0a2351806849476342f)
* ci/cd: fix recent Docker build failures on macOS | [a1922c5](https://github.com/undergroundwires/privacy.sexy/commit/a1922c50c12b3b7806e9e681ace842194a178bda)
* win: standardize registry edit + delete on revert | [cec0b4b](https://github.com/undergroundwires/privacy.sexy/commit/cec0b4b4f63c3563a0e7923ce6324a38d71a3955)
* Fix e2e test failing on Windows | [4a7efa2](https://github.com/undergroundwires/privacy.sexy/commit/4a7efa27c8df73ef9b7960afed29f216b066cba2)
* Add support for macOS universal binary #348, #362 | [d25c4e8](https://github.com/undergroundwires/privacy.sexy/commit/d25c4e8c812b8d012010ba38070a2931dcd28908)
* Migrate to GitHub issue forms | [9ab3ff7](https://github.com/undergroundwires/privacy.sexy/commit/9ab3ff75b0a69ac2ba27dd02e82db9b5bd76ea0f)
* ci/cd: fix quality checks not running on all OSes | [2390530](https://github.com/undergroundwires/privacy.sexy/commit/2390530d929fb92c266558c52376569a0ecb90c1)
* Bump Vue to latest and fix universal selector CSS | [aae5434](https://github.com/undergroundwires/privacy.sexy/commit/aae54344511ec51d17ad0420a92cb5a064e0e7bb)
* Centralize and optimize `ResizeObserver` usage | [2923621](https://github.com/undergroundwires/privacy.sexy/commit/292362135db0519ec1050bab80ed373aad115731)
* win: improve app access disabling and docs #138 | [ff3d5c4](https://github.com/undergroundwires/privacy.sexy/commit/ff3d5c48419f663379f5aba8936636c22f2c5de8)
* win: document and discourage RSA key script #363 | [f347fde](https://github.com/undergroundwires/privacy.sexy/commit/f347fde0c85f8b51b0060fdea0a2724b042aaeed)
* win: improve printing removal /w Print Queue #279 | [150e067](https://github.com/undergroundwires/privacy.sexy/commit/150e0670392bb62348c20ec644a4ed8a6bbffe74)
* win: discourage blocking app access #121 #339 #350 | [7794846](https://github.com/undergroundwires/privacy.sexy/commit/77948461856e6837ddfbcbbef72a1bf9fc706b4e)
* Improve context for errors thrown by compiler | [4212c7b](https://github.com/undergroundwires/privacy.sexy/commit/4212c7b9e0b1500378a1e4e88efc2d59f39f3d29)
* win: document disabling firewall #115 #152 #364 | [12b1f18](https://github.com/undergroundwires/privacy.sexy/commit/12b1f183f7ce966d6ce090d98aeea7ec491f8c7c)
* win: add script to disable Recall feature | [ce4cfdd](https://github.com/undergroundwires/privacy.sexy/commit/ce4cfdd169b7da0edc3da61143c988ed5f3c976e)
* win, mac, linux: fix typos and dead URLs #367 | [9e34e64](https://github.com/undergroundwires/privacy.sexy/commit/9e34e644493674ca709b64a47206763d5d4bd60c)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.13.3...0.13.4)
## 0.13.3 (2024-05-11)
* win: organize and document network disablement | [2eed6f4](https://github.com/undergroundwires/privacy.sexy/commit/2eed6f4afb6cf85fdc1d6acb808f82405a35cafd)
* win: improve disabling SMBv1 protocol | [f584fab](https://github.com/undergroundwires/privacy.sexy/commit/f584fabb50c7de70ba43751d721af94d8fa2fa8a)
* win: improve disabling insecure renegotiations | [f261ab4](https://github.com/undergroundwires/privacy.sexy/commit/f261ab4cd9a53e31325e5c6da9129542971fe84b)
* win: doc, improve, encourage cipher disabling | [8b224ee](https://github.com/undergroundwires/privacy.sexy/commit/8b224eefe71be6a556a1085d8fe20dbd4b889430)
* ci/cd: add check for TODO comments | [4e21f05](https://github.com/undergroundwires/privacy.sexy/commit/4e21f05031d6cc90cda684bd598bec4735f8103b)
* win: improve 'Snipping Tool' removal #343 | [e18907c](https://github.com/undergroundwires/privacy.sexy/commit/e18907ca91e483255b44d14d7d923d7eef92afbd)
* ci/cd: lint Python scripts using `pylint` | [23bac0f](https://github.com/undergroundwires/privacy.sexy/commit/23bac0fc76ad697abb34f3fb327df5cdeb40286a)
* win: improve disabling insecure hashes #131 | [d19dde6](https://github.com/undergroundwires/privacy.sexy/commit/d19dde603ddac47022ee2e0ea865d53857560c26)
* Add system requirements documentation #134 | [0fc2ffc](https://github.com/undergroundwires/privacy.sexy/commit/0fc2ffc1ea36a9248c6a92da85a29f7b04b33796)
* win, linux, mac: fix various typos #349 | [694bf1a](https://github.com/undergroundwires/privacy.sexy/commit/694bf1a74d935531d7cd46891823af1fa58c3c8c)
* Fix script cancellation with new dialog on Linux | [8c17396](https://github.com/undergroundwires/privacy.sexy/commit/8c173962857a39dc0c9e5886cb2af4937e6618e7)
* win: improve disabling protocols | [4ef16ce](https://github.com/undergroundwires/privacy.sexy/commit/4ef16cea56789120cd041412d86b5577cccf0725)
* win: fix Copilot by excluding `r.bing.com` #329 | [66a5688](https://github.com/undergroundwires/privacy.sexy/commit/66a56888a4b3ead1a6bfef0feffa0218535701fe)
* Fix blank window on load on desktop version #348 | [813d820](https://github.com/undergroundwires/privacy.sexy/commit/813d820b85e1b623c50f8e0325ad372bf2f344f9)
* Improve desktop icon quality and generation | [ab25e0a](https://github.com/undergroundwires/privacy.sexy/commit/ab25e0a066be14ea979dafd0f80e1091bd5d33f8)
* win: improve enabling secure connections #175 | [c75df1c](https://github.com/undergroundwires/privacy.sexy/commit/c75df1c8c1151b64cbf014383dea0b748a8c78b3)
* Fix VSCode script issues with added CI/CD tests | [1d7cafc](https://github.com/undergroundwires/privacy.sexy/commit/1d7cafc831dcc339a10646794410dad7096bfe60)
* Fix win execution with whitespace in username #351 | [a334320](https://github.com/undergroundwires/privacy.sexy/commit/a3343205b1196d5a81fd3cee2ae661ce871a7bef)
* Fix misaligned tooltip positions in modal dialogs | [dd71536](https://github.com/undergroundwires/privacy.sexy/commit/dd71536316ec819caeb418b8635d544ac80e58ad)
* Fix Chromium scrollbar-induced layout shifts | [bc4879c](https://github.com/undergroundwires/privacy.sexy/commit/bc4879cfe97becac3c54f6b40780a89464d3b772)
* ci/cd: remove `check-latest` from `setup-node` | [52a4730](https://github.com/undergroundwires/privacy.sexy/commit/52a4730073b8ebfb2ce9d530b44e4a179f5849fe)
* win: categorize and rename network security #131 | [9fd193e](https://github.com/undergroundwires/privacy.sexy/commit/9fd193e676f1f0646898f5130fbfaaf25050b2e3)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.13.2...0.13.3)
## 0.13.2 (2024-04-15)
* Update documentation for `logo-update.js` script | [4a9b430](https://github.com/undergroundwires/privacy.sexy/commit/4a9b430702bc6082426b50ecc3a06362b5720796)

View File

@@ -122,7 +122,7 @@
## Get started
- 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy).
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.2/privacy.sexy-Setup-0.13.2.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.2/privacy.sexy-0.13.2.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.2/privacy.sexy-0.13.2.AppImage). For more options, see [here](#additional-install-options).
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.4/privacy.sexy-Setup-0.13.4.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.4/privacy.sexy-0.13.4.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.4/privacy.sexy-0.13.4.AppImage). For more options, see [here](#additional-install-options).
See also:

View File

@@ -8,7 +8,7 @@ systems or configurations that haven't undergone official testing.
- **Version:** Windows 10 and later.
- **Processor:** Intel Pentium 4 or later.
- **Architecture:** 64-bit (x64), ARM.
- **Architecture:** 64-bit (x86-64), ARM (ARM64).
> **⚠️ Compatibility Note:**
> ARM version is only compatible with Windows 11 and later.
@@ -17,24 +17,20 @@ systems or configurations that haven't undergone official testing.
## macOS
- **Version:** macOS Catalina (10.15) and later.
- **Architecture:** Intel-based (64-bit), Apple Silicon (ARM).
> **⚠️ Compatibility Note:**
> Apple Silicon version runs non-natively, leading to slower performance due to emulation [2].
- **Architecture:** Intel-based (x86-64), Apple silicon (ARM64).
## Linux
- **Version:** Ubuntu 18.04 and later, Fedora 32 and later, and Debian 10 and later.
- **Processor:** Intel Pentium 4 or later.
- **Architecture:** 64-bit (x64).
- **Architecture:** 64-bit (x86-64).
## References
System requirements reflect Electron's platform capabilities [3] and Chromium's recommended configurations [4].
System requirements reflect Electron's platform capabilities [2] and Chromium's recommended configurations [3].
For details on the build process, see [electron-builder configuration file](./../../electron-builder.cjs).
[1]: https://web.archive.org/web/20240428082726/https://learn.microsoft.com/en-us/windows/arm/add-arm-support#emulation-on-arm-based-devices-for-x86-or-x64-windows-apps "Add support Arm devices to your Windows app | Microsoft Learn | learn.microsoft.com"
[2]: https://archive.today/2024.04.28-082901/https://developer.apple.com/documentation/apple-silicon/building-a-universal-macos-binary%23overview "Building a universal macOS binary | Apple Developer Documentation | developer.apple.com"
[3]: https://archive.ph/2024.04.28-082958/https://github.com/electron/electron/blob/main/README.md#platform-support "Platform Support | electron/README.md at main · electron/electron · GitHub | github.com"
[4]: https://web.archive.org/web/20240428082945/https://support.google.com/chrome/a/answer/7100626?hl=en "Chrome browser system requirements - Chrome Enterprise and Education Help | support.google.com"
[2]: https://archive.ph/2024.04.28-082958/https://github.com/electron/electron/blob/main/README.md#platform-support "Platform Support | electron/README.md at main · electron/electron · GitHub | github.com"
[3]: https://web.archive.org/web/20240428082945/https://support.google.com/chrome/a/answer/7100626?hl=en "Chrome browser system requirements - Chrome Enterprise and Education Help | support.google.com"

View File

@@ -43,7 +43,10 @@ module.exports = {
// macOS
mac: {
target: 'dmg',
target: {
target: 'dmg',
arch: 'universal',
},
},
dmg: {
artifactName: '${name}-${version}.${ext}',

263
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "privacy.sexy",
"version": "0.13.2",
"version": "0.13.4",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "privacy.sexy",
"version": "0.13.2",
"version": "0.13.3",
"hasInstallScript": true,
"dependencies": {
"@floating-ui/vue": "^1.0.6",
@@ -17,7 +17,7 @@
"electron-updater": "^6.1.9",
"file-saver": "^2.0.5",
"markdown-it": "^14.1.0",
"vue": "^3.4.21"
"vue": "^3.4.27"
},
"devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.1.0",
@@ -522,9 +522,9 @@
}
},
"node_modules/@babel/parser": {
"version": "7.24.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.0.tgz",
"integrity": "sha512-QuP/FxEAzMSjXygs8v4N9dvdXzEHN4W1oF3PxuWAtPo08UdM17u89RDMgjLn/mlc56iM0HlLmVkO/wgR+rDgHg==",
"version": "7.24.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.5.tgz",
"integrity": "sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg==",
"bin": {
"parser": "bin/babel-parser.js"
},
@@ -3947,49 +3947,49 @@
}
},
"node_modules/@vue/compiler-core": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.21.tgz",
"integrity": "sha512-MjXawxZf2SbZszLPYxaFCjxfibYrzr3eYbKxwpLR9EQN+oaziSu3qKVbwBERj1IFIB8OLUewxB5m/BFzi613og==",
"version": "3.4.27",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.27.tgz",
"integrity": "sha512-E+RyqY24KnyDXsCuQrI+mlcdW3ALND6U7Gqa/+bVwbcpcR3BRRIckFoz7Qyd4TTlnugtwuI7YgjbvsLmxb+yvg==",
"dependencies": {
"@babel/parser": "^7.23.9",
"@vue/shared": "3.4.21",
"@babel/parser": "^7.24.4",
"@vue/shared": "3.4.27",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.0.2"
"source-map-js": "^1.2.0"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.21.tgz",
"integrity": "sha512-IZC6FKowtT1sl0CR5DpXSiEB5ayw75oT2bma1BEhV7RRR1+cfwLrxc2Z8Zq/RGFzJ8w5r9QtCOvTjQgdn0IKmA==",
"version": "3.4.27",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.27.tgz",
"integrity": "sha512-kUTvochG/oVgE1w5ViSr3KUBh9X7CWirebA3bezTbB5ZKBQZwR2Mwj9uoSKRMFcz4gSMzzLXBPD6KpCLb9nvWw==",
"dependencies": {
"@vue/compiler-core": "3.4.21",
"@vue/shared": "3.4.21"
"@vue/compiler-core": "3.4.27",
"@vue/shared": "3.4.27"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.21.tgz",
"integrity": "sha512-me7epoTxYlY+2CUM7hy9PCDdpMPfIwrOvAXud2Upk10g4YLv9UBW7kL798TvMeDhPthkZ0CONNrK2GoeI1ODiQ==",
"version": "3.4.27",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.27.tgz",
"integrity": "sha512-nDwntUEADssW8e0rrmE0+OrONwmRlegDA1pD6QhVeXxjIytV03yDqTey9SBDiALsvAd5U4ZrEKbMyVXhX6mCGA==",
"dependencies": {
"@babel/parser": "^7.23.9",
"@vue/compiler-core": "3.4.21",
"@vue/compiler-dom": "3.4.21",
"@vue/compiler-ssr": "3.4.21",
"@vue/shared": "3.4.21",
"@babel/parser": "^7.24.4",
"@vue/compiler-core": "3.4.27",
"@vue/compiler-dom": "3.4.27",
"@vue/compiler-ssr": "3.4.27",
"@vue/shared": "3.4.27",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.7",
"postcss": "^8.4.35",
"source-map-js": "^1.0.2"
"magic-string": "^0.30.10",
"postcss": "^8.4.38",
"source-map-js": "^1.2.0"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.21.tgz",
"integrity": "sha512-M5+9nI2lPpAsgXOGQobnIueVqc9sisBFexh5yMIMRAPYLa7+5wEJs8iqOZc1WAa9WQbx9GR2twgznU8LTIiZ4Q==",
"version": "3.4.27",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.27.tgz",
"integrity": "sha512-CVRzSJIltzMG5FcidsW0jKNQnNRYC8bT21VegyMMtHmhW3UOI7knmUehzswXLrExDLE6lQCZdrhD4ogI7c+vuw==",
"dependencies": {
"@vue/compiler-dom": "3.4.21",
"@vue/shared": "3.4.21"
"@vue/compiler-dom": "3.4.27",
"@vue/shared": "3.4.27"
}
},
"node_modules/@vue/eslint-config-airbnb": {
@@ -4099,48 +4099,48 @@
}
},
"node_modules/@vue/reactivity": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.21.tgz",
"integrity": "sha512-UhenImdc0L0/4ahGCyEzc/pZNwVgcglGy9HVzJ1Bq2Mm9qXOpP8RyNTjookw/gOCUlXSEtuZ2fUg5nrHcoqJcw==",
"version": "3.4.27",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.27.tgz",
"integrity": "sha512-kK0g4NknW6JX2yySLpsm2jlunZJl2/RJGZ0H9ddHdfBVHcNzxmQ0sS0b09ipmBoQpY8JM2KmUw+a6sO8Zo+zIA==",
"dependencies": {
"@vue/shared": "3.4.21"
"@vue/shared": "3.4.27"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.21.tgz",
"integrity": "sha512-pQthsuYzE1XcGZznTKn73G0s14eCJcjaLvp3/DKeYWoFacD9glJoqlNBxt3W2c5S40t6CCcpPf+jG01N3ULyrA==",
"version": "3.4.27",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.27.tgz",
"integrity": "sha512-7aYA9GEbOOdviqVvcuweTLe5Za4qBZkUY7SvET6vE8kyypxVgaT1ixHLg4urtOlrApdgcdgHoTZCUuTGap/5WA==",
"dependencies": {
"@vue/reactivity": "3.4.21",
"@vue/shared": "3.4.21"
"@vue/reactivity": "3.4.27",
"@vue/shared": "3.4.27"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.21.tgz",
"integrity": "sha512-gvf+C9cFpevsQxbkRBS1NpU8CqxKw0ebqMvLwcGQrNpx6gqRDodqKqA+A2VZZpQ9RpK2f9yfg8VbW/EpdFUOJw==",
"version": "3.4.27",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.27.tgz",
"integrity": "sha512-ScOmP70/3NPM+TW9hvVAz6VWWtZJqkbdf7w6ySsws+EsqtHvkhxaWLecrTorFxsawelM5Ys9FnDEMt6BPBDS0Q==",
"dependencies": {
"@vue/runtime-core": "3.4.21",
"@vue/shared": "3.4.21",
"@vue/runtime-core": "3.4.27",
"@vue/shared": "3.4.27",
"csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.21.tgz",
"integrity": "sha512-aV1gXyKSN6Rz+6kZ6kr5+Ll14YzmIbeuWe7ryJl5muJ4uwSwY/aStXTixx76TwkZFJLm1aAlA/HSWEJ4EyiMkg==",
"version": "3.4.27",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.27.tgz",
"integrity": "sha512-dlAMEuvmeA3rJsOMJ2J1kXU7o7pOxgsNHVr9K8hB3ImIkSuBrIdy0vF66h8gf8Tuinf1TK3mPAz2+2sqyf3KzA==",
"dependencies": {
"@vue/compiler-ssr": "3.4.21",
"@vue/shared": "3.4.21"
"@vue/compiler-ssr": "3.4.27",
"@vue/shared": "3.4.27"
},
"peerDependencies": {
"vue": "3.4.21"
"vue": "3.4.27"
}
},
"node_modules/@vue/shared": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.21.tgz",
"integrity": "sha512-PuJe7vDIi6VYSinuEbUIQgMIRZGgM8e4R+G+/dQTk0X1NEdvgvvgv7m+rfmDH1gZzyA1OjjoWskvHlfRNfQf3g=="
"version": "3.4.27",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.27.tgz",
"integrity": "sha512-DL3NmY2OFlqmYYrzp39yi3LDkKxa5vZVwxWdQ3rG0ekuWscHraeIbnI8t+aZK7qhYqEqWKTUdijadunb9pnrgA=="
},
"node_modules/@vue/test-utils": {
"version": "2.4.5",
@@ -10777,14 +10777,11 @@
}
},
"node_modules/magic-string": {
"version": "0.30.8",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
"integrity": "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==",
"version": "0.30.10",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz",
"integrity": "sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
},
"engines": {
"node": ">=12"
}
},
"node_modules/map-age-cleaner": {
@@ -16982,15 +16979,15 @@
}
},
"node_modules/vue": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.4.21.tgz",
"integrity": "sha512-5hjyV/jLEIKD/jYl4cavMcnzKwjMKohureP8ejn3hhEjwhWIhWeuzL2kJAjzl/WyVsgPY56Sy4Z40C3lVshxXA==",
"version": "3.4.27",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.4.27.tgz",
"integrity": "sha512-8s/56uK6r01r1icG/aEOHqyMVxd1bkYcSe9j8HcKtr/xTOFWvnzIVTehNW+5Yt89f+DLBe4A569pnZLS5HzAMA==",
"dependencies": {
"@vue/compiler-dom": "3.4.21",
"@vue/compiler-sfc": "3.4.21",
"@vue/runtime-dom": "3.4.21",
"@vue/server-renderer": "3.4.21",
"@vue/shared": "3.4.21"
"@vue/compiler-dom": "3.4.27",
"@vue/compiler-sfc": "3.4.27",
"@vue/runtime-dom": "3.4.27",
"@vue/server-renderer": "3.4.27",
"@vue/shared": "3.4.27"
},
"peerDependencies": {
"typescript": "*"
@@ -17918,9 +17915,9 @@
}
},
"@babel/parser": {
"version": "7.24.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.0.tgz",
"integrity": "sha512-QuP/FxEAzMSjXygs8v4N9dvdXzEHN4W1oF3PxuWAtPo08UdM17u89RDMgjLn/mlc56iM0HlLmVkO/wgR+rDgHg=="
"version": "7.24.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.5.tgz",
"integrity": "sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg=="
},
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
"version": "7.23.3",
@@ -20277,49 +20274,49 @@
}
},
"@vue/compiler-core": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.21.tgz",
"integrity": "sha512-MjXawxZf2SbZszLPYxaFCjxfibYrzr3eYbKxwpLR9EQN+oaziSu3qKVbwBERj1IFIB8OLUewxB5m/BFzi613og==",
"version": "3.4.27",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.27.tgz",
"integrity": "sha512-E+RyqY24KnyDXsCuQrI+mlcdW3ALND6U7Gqa/+bVwbcpcR3BRRIckFoz7Qyd4TTlnugtwuI7YgjbvsLmxb+yvg==",
"requires": {
"@babel/parser": "^7.23.9",
"@vue/shared": "3.4.21",
"@babel/parser": "^7.24.4",
"@vue/shared": "3.4.27",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.0.2"
"source-map-js": "^1.2.0"
}
},
"@vue/compiler-dom": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.21.tgz",
"integrity": "sha512-IZC6FKowtT1sl0CR5DpXSiEB5ayw75oT2bma1BEhV7RRR1+cfwLrxc2Z8Zq/RGFzJ8w5r9QtCOvTjQgdn0IKmA==",
"version": "3.4.27",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.27.tgz",
"integrity": "sha512-kUTvochG/oVgE1w5ViSr3KUBh9X7CWirebA3bezTbB5ZKBQZwR2Mwj9uoSKRMFcz4gSMzzLXBPD6KpCLb9nvWw==",
"requires": {
"@vue/compiler-core": "3.4.21",
"@vue/shared": "3.4.21"
"@vue/compiler-core": "3.4.27",
"@vue/shared": "3.4.27"
}
},
"@vue/compiler-sfc": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.21.tgz",
"integrity": "sha512-me7epoTxYlY+2CUM7hy9PCDdpMPfIwrOvAXud2Upk10g4YLv9UBW7kL798TvMeDhPthkZ0CONNrK2GoeI1ODiQ==",
"version": "3.4.27",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.27.tgz",
"integrity": "sha512-nDwntUEADssW8e0rrmE0+OrONwmRlegDA1pD6QhVeXxjIytV03yDqTey9SBDiALsvAd5U4ZrEKbMyVXhX6mCGA==",
"requires": {
"@babel/parser": "^7.23.9",
"@vue/compiler-core": "3.4.21",
"@vue/compiler-dom": "3.4.21",
"@vue/compiler-ssr": "3.4.21",
"@vue/shared": "3.4.21",
"@babel/parser": "^7.24.4",
"@vue/compiler-core": "3.4.27",
"@vue/compiler-dom": "3.4.27",
"@vue/compiler-ssr": "3.4.27",
"@vue/shared": "3.4.27",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.7",
"postcss": "^8.4.35",
"source-map-js": "^1.0.2"
"magic-string": "^0.30.10",
"postcss": "^8.4.38",
"source-map-js": "^1.2.0"
}
},
"@vue/compiler-ssr": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.21.tgz",
"integrity": "sha512-M5+9nI2lPpAsgXOGQobnIueVqc9sisBFexh5yMIMRAPYLa7+5wEJs8iqOZc1WAa9WQbx9GR2twgznU8LTIiZ4Q==",
"version": "3.4.27",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.27.tgz",
"integrity": "sha512-CVRzSJIltzMG5FcidsW0jKNQnNRYC8bT21VegyMMtHmhW3UOI7knmUehzswXLrExDLE6lQCZdrhD4ogI7c+vuw==",
"requires": {
"@vue/compiler-dom": "3.4.21",
"@vue/shared": "3.4.21"
"@vue/compiler-dom": "3.4.27",
"@vue/shared": "3.4.27"
}
},
"@vue/eslint-config-airbnb": {
@@ -20395,45 +20392,45 @@
}
},
"@vue/reactivity": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.21.tgz",
"integrity": "sha512-UhenImdc0L0/4ahGCyEzc/pZNwVgcglGy9HVzJ1Bq2Mm9qXOpP8RyNTjookw/gOCUlXSEtuZ2fUg5nrHcoqJcw==",
"version": "3.4.27",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.27.tgz",
"integrity": "sha512-kK0g4NknW6JX2yySLpsm2jlunZJl2/RJGZ0H9ddHdfBVHcNzxmQ0sS0b09ipmBoQpY8JM2KmUw+a6sO8Zo+zIA==",
"requires": {
"@vue/shared": "3.4.21"
"@vue/shared": "3.4.27"
}
},
"@vue/runtime-core": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.21.tgz",
"integrity": "sha512-pQthsuYzE1XcGZznTKn73G0s14eCJcjaLvp3/DKeYWoFacD9glJoqlNBxt3W2c5S40t6CCcpPf+jG01N3ULyrA==",
"version": "3.4.27",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.27.tgz",
"integrity": "sha512-7aYA9GEbOOdviqVvcuweTLe5Za4qBZkUY7SvET6vE8kyypxVgaT1ixHLg4urtOlrApdgcdgHoTZCUuTGap/5WA==",
"requires": {
"@vue/reactivity": "3.4.21",
"@vue/shared": "3.4.21"
"@vue/reactivity": "3.4.27",
"@vue/shared": "3.4.27"
}
},
"@vue/runtime-dom": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.21.tgz",
"integrity": "sha512-gvf+C9cFpevsQxbkRBS1NpU8CqxKw0ebqMvLwcGQrNpx6gqRDodqKqA+A2VZZpQ9RpK2f9yfg8VbW/EpdFUOJw==",
"version": "3.4.27",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.27.tgz",
"integrity": "sha512-ScOmP70/3NPM+TW9hvVAz6VWWtZJqkbdf7w6ySsws+EsqtHvkhxaWLecrTorFxsawelM5Ys9FnDEMt6BPBDS0Q==",
"requires": {
"@vue/runtime-core": "3.4.21",
"@vue/shared": "3.4.21",
"@vue/runtime-core": "3.4.27",
"@vue/shared": "3.4.27",
"csstype": "^3.1.3"
}
},
"@vue/server-renderer": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.21.tgz",
"integrity": "sha512-aV1gXyKSN6Rz+6kZ6kr5+Ll14YzmIbeuWe7ryJl5muJ4uwSwY/aStXTixx76TwkZFJLm1aAlA/HSWEJ4EyiMkg==",
"version": "3.4.27",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.27.tgz",
"integrity": "sha512-dlAMEuvmeA3rJsOMJ2J1kXU7o7pOxgsNHVr9K8hB3ImIkSuBrIdy0vF66h8gf8Tuinf1TK3mPAz2+2sqyf3KzA==",
"requires": {
"@vue/compiler-ssr": "3.4.21",
"@vue/shared": "3.4.21"
"@vue/compiler-ssr": "3.4.27",
"@vue/shared": "3.4.27"
}
},
"@vue/shared": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.21.tgz",
"integrity": "sha512-PuJe7vDIi6VYSinuEbUIQgMIRZGgM8e4R+G+/dQTk0X1NEdvgvvgv7m+rfmDH1gZzyA1OjjoWskvHlfRNfQf3g=="
"version": "3.4.27",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.27.tgz",
"integrity": "sha512-DL3NmY2OFlqmYYrzp39yi3LDkKxa5vZVwxWdQ3rG0ekuWscHraeIbnI8t+aZK7qhYqEqWKTUdijadunb9pnrgA=="
},
"@vue/test-utils": {
"version": "2.4.5",
@@ -25486,9 +25483,9 @@
}
},
"magic-string": {
"version": "0.30.8",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
"integrity": "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==",
"version": "0.30.10",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz",
"integrity": "sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==",
"requires": {
"@jridgewell/sourcemap-codec": "^1.4.15"
}
@@ -29953,15 +29950,15 @@
}
},
"vue": {
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.4.21.tgz",
"integrity": "sha512-5hjyV/jLEIKD/jYl4cavMcnzKwjMKohureP8ejn3hhEjwhWIhWeuzL2kJAjzl/WyVsgPY56Sy4Z40C3lVshxXA==",
"version": "3.4.27",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.4.27.tgz",
"integrity": "sha512-8s/56uK6r01r1icG/aEOHqyMVxd1bkYcSe9j8HcKtr/xTOFWvnzIVTehNW+5Yt89f+DLBe4A569pnZLS5HzAMA==",
"requires": {
"@vue/compiler-dom": "3.4.21",
"@vue/compiler-sfc": "3.4.21",
"@vue/runtime-dom": "3.4.21",
"@vue/server-renderer": "3.4.21",
"@vue/shared": "3.4.21"
"@vue/compiler-dom": "3.4.27",
"@vue/compiler-sfc": "3.4.27",
"@vue/runtime-dom": "3.4.27",
"@vue/server-renderer": "3.4.27",
"@vue/shared": "3.4.27"
}
},
"vue-component-type-helpers": {

View File

@@ -1,6 +1,6 @@
{
"name": "privacy.sexy",
"version": "0.13.2",
"version": "0.13.4",
"private": true,
"slogan": "Privacy is sexy",
"description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy.",
@@ -42,7 +42,7 @@
"electron-updater": "^6.1.9",
"file-saver": "^2.0.5",
"markdown-it": "^14.1.0",
"vue": "^3.4.21"
"vue": "^3.4.27"
},
"devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.1.0",

View File

@@ -1,4 +1,4 @@
import { isFunction } from '@/TypeHelpers';
import { isFunction, type ConstructorArguments } from '@/TypeHelpers';
/*
Provides a unified and resilient way to extend errors across platforms.
@@ -12,8 +12,8 @@ import { isFunction } from '@/TypeHelpers';
> https://web.archive.org/web/20230810014143/https://github.com/Microsoft/TypeScript-wiki/blob/main/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work
*/
export abstract class CustomError extends Error {
constructor(message?: string, options?: ErrorOptions) {
super(message, options);
constructor(...args: ConstructorArguments<typeof Error>) {
super(...args);
fixPrototype(this, new.target.prototype);
ensureStackTrace(this);

View File

@@ -15,18 +15,26 @@ const DefaultOptions: ThrottleOptions = {
timer: PlatformTimer,
};
export function throttle(
export interface ThrottleFunction {
(
callback: CallbackType,
waitInMs: number,
options?: Partial<ThrottleOptions>,
): CallbackType;
}
export const throttle: ThrottleFunction = (
callback: CallbackType,
waitInMs: number,
options: Partial<ThrottleOptions> = DefaultOptions,
): CallbackType {
): CallbackType => {
const defaultedOptions: ThrottleOptions = {
...DefaultOptions,
...options,
};
const throttler = new Throttler(waitInMs, callback, defaultedOptions);
return (...args: unknown[]) => throttler.invoke(...args);
}
};
class Throttler {
private lastExecutionTime: number | null = null;

View File

@@ -3,10 +3,12 @@ import type {
} from '@/application/collections/';
import { Script } from '@/domain/Script';
import { Category } from '@/domain/Category';
import { NodeValidator } from '@/application/Parser/NodeValidation/NodeValidator';
import { NodeType } from '@/application/Parser/NodeValidation/NodeType';
import { parseDocs } from './DocumentationParser';
import { parseScript } from './Script/ScriptParser';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import type { ICategory } from '@/domain/ICategory';
import { parseDocs, type DocsParser } from './DocumentationParser';
import { parseScript, type ScriptParser } from './Script/ScriptParser';
import { createNodeDataValidator, type NodeDataValidator, type NodeDataValidatorFactory } from './NodeValidation/NodeDataValidator';
import { NodeDataType } from './NodeValidation/NodeDataType';
import type { ICategoryCollectionParseContext } from './Script/ICategoryCollectionParseContext';
let categoryIdCounter = 0;
@@ -14,96 +16,108 @@ let categoryIdCounter = 0;
export function parseCategory(
category: CategoryData,
context: ICategoryCollectionParseContext,
factory: CategoryFactoryType = CategoryFactory,
utilities: CategoryParserUtilities = DefaultCategoryParserUtilities,
): Category {
return parseCategoryRecursively({
categoryData: category,
context,
factory,
utilities,
});
}
interface ICategoryParseContext {
readonly categoryData: CategoryData,
readonly context: ICategoryCollectionParseContext,
readonly factory: CategoryFactoryType,
readonly parentCategory?: CategoryData,
interface CategoryParseContext {
readonly categoryData: CategoryData;
readonly context: ICategoryCollectionParseContext;
readonly parentCategory?: CategoryData;
readonly utilities: CategoryParserUtilities;
}
function parseCategoryRecursively(context: ICategoryParseContext): Category | never {
ensureValidCategory(context.categoryData, context.parentCategory);
const children: ICategoryChildren = {
subCategories: new Array<Category>(),
subScripts: new Array<Script>(),
function parseCategoryRecursively(
context: CategoryParseContext,
): Category | never {
const validator = ensureValidCategory(context);
const children: CategoryChildren = {
subcategories: new Array<Category>(),
subscripts: new Array<Script>(),
};
for (const data of context.categoryData.children) {
parseNode({
nodeData: data,
children,
parent: context.categoryData,
factory: context.factory,
utilities: context.utilities,
context: context.context,
});
}
try {
return context.factory(
/* id: */ categoryIdCounter++,
/* name: */ context.categoryData.category,
/* docs: */ parseDocs(context.categoryData),
/* categories: */ children.subCategories,
/* scripts: */ children.subScripts,
return context.utilities.createCategory({
id: categoryIdCounter++,
name: context.categoryData.category,
docs: context.utilities.parseDocs(context.categoryData),
subcategories: children.subcategories,
scripts: children.subscripts,
});
} catch (error) {
throw context.utilities.wrapError(
error,
validator.createContextualErrorMessage('Failed to parse category.'),
);
} catch (err) {
return new NodeValidator({
type: NodeType.Category,
selfNode: context.categoryData,
parentNode: context.parentCategory,
}).throw(err.message);
}
}
function ensureValidCategory(category: CategoryData, parentCategory?: CategoryData) {
new NodeValidator({
type: NodeType.Category,
selfNode: category,
parentNode: parentCategory,
})
.assertDefined(category)
.assertValidName(category.category)
.assert(
() => category.children.length > 0,
`"${category.category}" has no children.`,
);
function ensureValidCategory(
context: CategoryParseContext,
): NodeDataValidator {
const category = context.categoryData;
const validator: NodeDataValidator = context.utilities.createValidator({
type: NodeDataType.Category,
selfNode: context.categoryData,
parentNode: context.parentCategory,
});
validator.assertDefined(category);
validator.assertValidName(category.category);
validator.assert(
() => Boolean(category.children) && category.children.length > 0,
`"${category.category}" has no children.`,
);
return validator;
}
interface ICategoryChildren {
subCategories: Category[];
subScripts: Script[];
interface CategoryChildren {
readonly subcategories: Category[];
readonly subscripts: Script[];
}
interface INodeParseContext {
interface NodeParseContext {
readonly nodeData: CategoryOrScriptData;
readonly children: ICategoryChildren;
readonly children: CategoryChildren;
readonly parent: CategoryData;
readonly factory: CategoryFactoryType;
readonly context: ICategoryCollectionParseContext;
readonly utilities: CategoryParserUtilities;
}
function parseNode(context: INodeParseContext) {
const validator = new NodeValidator({ selfNode: context.nodeData, parentNode: context.parent });
function parseNode(context: NodeParseContext) {
const validator: NodeDataValidator = context.utilities.createValidator({
selfNode: context.nodeData,
parentNode: context.parent,
});
validator.assertDefined(context.nodeData);
validator.assert(
() => isCategory(context.nodeData) || isScript(context.nodeData),
'Node is neither a category or a script.',
);
if (isCategory(context.nodeData)) {
const subCategory = parseCategoryRecursively({
categoryData: context.nodeData,
context: context.context,
factory: context.factory,
parentCategory: context.parent,
utilities: context.utilities,
});
context.children.subCategories.push(subCategory);
} else if (isScript(context.nodeData)) {
const script = parseScript(context.nodeData, context.context);
context.children.subScripts.push(script);
} else {
validator.throw('Node is neither a category or a script.');
context.children.subcategories.push(subCategory);
} else { // A script
const script = context.utilities.parseScript(context.nodeData, context.context);
context.children.subscripts.push(script);
}
}
@@ -123,11 +137,35 @@ function hasCall(data: unknown) {
return hasProperty(data, 'call');
}
function hasProperty(object: unknown, propertyName: string) {
function hasProperty(
object: unknown,
propertyName: string,
): object is NonNullable<object> {
if (typeof object !== 'object') {
return false;
}
if (object === null) { // `typeof object` is `null`
return false;
}
return Object.prototype.hasOwnProperty.call(object, propertyName);
}
export type CategoryFactoryType = (
...parameters: ConstructorParameters<typeof Category>) => Category;
export type CategoryFactory = (
...parameters: ConstructorParameters<typeof Category>
) => ICategory;
const CategoryFactory: CategoryFactoryType = (...parameters) => new Category(...parameters);
interface CategoryParserUtilities {
readonly createCategory: CategoryFactory;
readonly wrapError: ErrorWithContextWrapper;
readonly createValidator: NodeDataValidatorFactory;
readonly parseScript: ScriptParser;
readonly parseDocs: DocsParser;
}
const DefaultCategoryParserUtilities: CategoryParserUtilities = {
createCategory: (...parameters) => new Category(...parameters),
wrapError: wrapErrorWithAdditionalContext,
createValidator: createNodeDataValidator,
parseScript,
parseDocs,
};

View File

@@ -0,0 +1,42 @@
import { CustomError } from '@/application/Common/CustomError';
export interface ErrorWithContextWrapper {
(
error: Error,
additionalContext: string,
): Error;
}
export const wrapErrorWithAdditionalContext: ErrorWithContextWrapper = (
error: Error,
additionalContext: string,
) => {
return (error instanceof ContextualError ? error : new ContextualError(error))
.withAdditionalContext(additionalContext);
};
/* AggregateError is similar but isn't well-serialized or displayed by browsers */
class ContextualError extends CustomError {
private readonly additionalContext = new Array<string>();
constructor(
public readonly innerError: Error,
) {
super();
}
public withAdditionalContext(additionalContext: string): this {
this.additionalContext.push(additionalContext);
return this;
}
public get message(): string { // toString() is not used when Chromium logs it on console
return [
'\n',
this.innerError.message,
'\n',
'Additional context:',
...this.additionalContext.map((context, index) => `${index + 1}: ${context}`),
].join('\n');
}
}

View File

@@ -1,7 +1,7 @@
import type { DocumentableData, DocumentationData } from '@/application/collections/';
import { isString, isArray } from '@/TypeHelpers';
export function parseDocs(documentable: DocumentableData): readonly string[] {
export const parseDocs: DocsParser = (documentable) => {
const { docs } = documentable;
if (!docs) {
return [];
@@ -9,6 +9,12 @@ export function parseDocs(documentable: DocumentableData): readonly string[] {
let result = new DocumentationContainer();
result = addDocs(docs, result);
return result.getAll();
};
export interface DocsParser {
(
documentable: DocumentableData,
): readonly string[];
}
function addDocs(

View File

@@ -1,34 +0,0 @@
import { CustomError } from '@/application/Common/CustomError';
import { NodeType } from './NodeType';
import type { NodeData } from './NodeData';
export class NodeDataError extends CustomError {
constructor(message: string, public readonly context: INodeDataErrorContext) {
super(createMessage(message, context));
}
}
export interface INodeDataErrorContext {
readonly type?: NodeType;
readonly selfNode: NodeData;
readonly parentNode?: NodeData;
}
function createMessage(errorMessage: string, context: INodeDataErrorContext) {
let message = '';
if (context.type !== undefined) {
message += `${NodeType[context.type]}: `;
}
message += errorMessage;
message += `\n${dump(context)}`;
return message;
}
function dump(context: INodeDataErrorContext): string {
const printJson = (obj: unknown) => JSON.stringify(obj, undefined, 2);
let output = `Self: ${printJson(context.selfNode)}`;
if (context.parentNode) {
output += `\nParent: ${printJson(context.parentNode)}`;
}
return output;
}

View File

@@ -0,0 +1,25 @@
import type { CategoryData, ScriptData } from '@/application/collections/';
import { NodeDataType } from './NodeDataType';
import type { NodeData } from './NodeData';
export type NodeDataErrorContext = {
readonly parentNode?: CategoryData;
} & (CategoryNodeErrorContext | ScriptNodeErrorContext | UnknownNodeErrorContext);
export type CategoryNodeErrorContext = {
readonly type: NodeDataType.Category;
readonly selfNode: CategoryData;
readonly parentNode?: CategoryData;
};
export type ScriptNodeErrorContext = {
readonly type: NodeDataType.Script;
readonly selfNode: ScriptData;
readonly parentNode?: CategoryData;
};
export type UnknownNodeErrorContext = {
readonly type?: undefined;
readonly selfNode: NodeData;
readonly parentNode?: CategoryData;
};

View File

@@ -0,0 +1,35 @@
import { NodeDataType } from './NodeDataType';
import type { NodeDataErrorContext } from './NodeDataErrorContext';
import type { NodeData } from './NodeData';
export interface NodeContextErrorMessageCreator {
(
errorMessage: string,
context: NodeDataErrorContext,
): string;
}
export const createNodeContextErrorMessage: NodeContextErrorMessageCreator = (
errorMessage,
context,
) => {
let message = '';
if (context.type !== undefined) {
message += `${NodeDataType[context.type]}: `;
}
message += errorMessage;
message += `\n${getErrorContextDetails(context)}`;
return message;
};
function getErrorContextDetails(context: NodeDataErrorContext): string {
let output = `Self: ${printNodeDataAsJson(context.selfNode)}`;
if (context.parentNode) {
output += `\nParent: ${printNodeDataAsJson(context.parentNode)}`;
}
return output;
}
function printNodeDataAsJson(node: NodeData): string {
return JSON.stringify(node, undefined, 2);
}

View File

@@ -0,0 +1,4 @@
export enum NodeDataType {
Script,
Category,
}

View File

@@ -0,0 +1,69 @@
import { isString } from '@/TypeHelpers';
import { type NodeDataErrorContext } from './NodeDataErrorContext';
import { createNodeContextErrorMessage, type NodeContextErrorMessageCreator } from './NodeDataErrorContextMessage';
import type { NodeData } from './NodeData';
export interface NodeDataValidatorFactory {
(context: NodeDataErrorContext): NodeDataValidator;
}
export interface NodeDataValidator {
assertValidName(nameValue: string): void;
assertDefined(
node: NodeData | undefined,
): asserts node is NonNullable<NodeData> & void;
assert(
validationPredicate: () => boolean,
errorMessage: string,
): asserts validationPredicate is (() => true);
createContextualErrorMessage(errorMessage: string): string;
}
export const createNodeDataValidator
: NodeDataValidatorFactory = (context) => new ContextualNodeDataValidator(context);
export class ContextualNodeDataValidator implements NodeDataValidator {
constructor(
private readonly context: NodeDataErrorContext,
private readonly createErrorMessage
: NodeContextErrorMessageCreator = createNodeContextErrorMessage,
) {
}
public assertValidName(nameValue: string): void {
this.assert(() => Boolean(nameValue), 'missing name');
this.assert(
() => isString(nameValue),
`Name (${JSON.stringify(nameValue)}) is not a string but ${typeof nameValue}.`,
);
}
public assertDefined(
node: NodeData,
): asserts node is NonNullable<NodeData> {
this.assert(
() => node !== undefined && node !== null && Object.keys(node).length > 0,
'missing node data',
);
}
public assert(
validationPredicate: () => boolean,
errorMessage: string,
): asserts validationPredicate is (() => true) {
if (!validationPredicate()) {
this.throw(errorMessage);
}
}
public createContextualErrorMessage(errorMessage: string): string {
return this.createErrorMessage(errorMessage, this.context);
}
private throw(errorMessage: string): never {
throw new Error(
this.createContextualErrorMessage(errorMessage),
);
}
}

View File

@@ -1,4 +0,0 @@
export enum NodeType {
Script,
Category,
}

View File

@@ -1,39 +0,0 @@
import { isString } from '@/TypeHelpers';
import { type INodeDataErrorContext, NodeDataError } from './NodeDataError';
import type { NodeData } from './NodeData';
export class NodeValidator {
constructor(private readonly context: INodeDataErrorContext) {
}
public assertValidName(nameValue: string) {
return this
.assert(
() => Boolean(nameValue),
'missing name',
)
.assert(
() => isString(nameValue),
`Name (${JSON.stringify(nameValue)}) is not a string but ${typeof nameValue}.`,
);
}
public assertDefined(node: NodeData) {
return this.assert(
() => node !== undefined && node !== null && Object.keys(node).length > 0,
'missing node data',
);
}
public assert(validationPredicate: () => boolean, errorMessage: string) {
if (!validationPredicate()) {
this.throw(errorMessage);
}
return this;
}
public throw(errorMessage: string): never {
throw new NodeDataError(errorMessage, this.context);
}
}

View File

@@ -7,15 +7,18 @@ import type { IReadOnlyFunctionParameterCollection } from '../../Function/Parame
import type { IExpression } from './IExpression';
export type ExpressionEvaluator = (context: IExpressionEvaluationContext) => string;
export class Expression implements IExpression {
public readonly parameters: IReadOnlyFunctionParameterCollection;
constructor(
public readonly position: ExpressionPosition,
public readonly evaluator: ExpressionEvaluator,
parameters?: IReadOnlyFunctionParameterCollection,
) {
this.parameters = parameters ?? new FunctionParameterCollection();
public readonly position: ExpressionPosition;
public readonly evaluator: ExpressionEvaluator;
constructor(parameters: ExpressionInitParameters) {
this.parameters = parameters.parameters ?? new FunctionParameterCollection();
this.evaluator = parameters.evaluator;
this.position = parameters.position;
}
public evaluate(context: IExpressionEvaluationContext): string {
@@ -26,6 +29,12 @@ export class Expression implements IExpression {
}
}
export interface ExpressionInitParameters {
readonly position: ExpressionPosition,
readonly evaluator: ExpressionEvaluator,
readonly parameters?: IReadOnlyFunctionParameterCollection,
}
function validateThatAllRequiredParametersAreSatisfied(
parameters: IReadOnlyFunctionParameterCollection,
args: IReadOnlyFunctionCallArgumentCollection,

View File

@@ -1,8 +1,13 @@
import { ExpressionPosition } from './ExpressionPosition';
export function createPositionFromRegexFullMatch(
match: RegExpMatchArray,
): ExpressionPosition {
export interface ExpressionPositionFactory {
(
match: RegExpMatchArray,
): ExpressionPosition
}
export const createPositionFromRegexFullMatch
: ExpressionPositionFactory = (match) => {
const startPos = match.index;
if (startPos === undefined) {
throw new Error(`Regex match did not yield any results: ${JSON.stringify(match)}`);
@@ -13,4 +18,4 @@ export function createPositionFromRegexFullMatch(
}
const endPos = startPos + fullMatch.length;
return new ExpressionPosition(startPos, endPos);
}
};

View File

@@ -3,10 +3,10 @@ import { WithParser } from '../SyntaxParsers/WithParser';
import type { IExpression } from '../Expression/IExpression';
import type { IExpressionParser } from './IExpressionParser';
const Parsers = [
const Parsers: readonly IExpressionParser[] = [
new ParameterSubstitutionParser(),
new WithParser(),
];
] as const;
export class CompositeExpressionParser implements IExpressionParser {
public constructor(private readonly leafs: readonly IExpressionParser[] = Parsers) {

View File

@@ -1,53 +1,127 @@
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { Expression, type ExpressionEvaluator } from '../../Expression/Expression';
import { FunctionParameterCollection } from '../../../Function/Parameter/FunctionParameterCollection';
import { createPositionFromRegexFullMatch } from '../../Expression/ExpressionPositionFactory';
import { createPositionFromRegexFullMatch, type ExpressionPositionFactory } from '../../Expression/ExpressionPositionFactory';
import { createFunctionParameterCollection, type FunctionParameterCollectionFactory } from '../../../Function/Parameter/FunctionParameterCollectionFactory';
import type { IExpressionParser } from '../IExpressionParser';
import type { IExpression } from '../../Expression/IExpression';
import type { IFunctionParameter } from '../../../Function/Parameter/IFunctionParameter';
import type { IFunctionParameterCollection, IReadOnlyFunctionParameterCollection } from '../../../Function/Parameter/IFunctionParameterCollection';
export interface RegexParserUtilities {
readonly wrapError: ErrorWithContextWrapper;
readonly createPosition: ExpressionPositionFactory;
readonly createExpression: ExpressionFactory;
readonly createParameterCollection: FunctionParameterCollectionFactory;
}
export abstract class RegexParser implements IExpressionParser {
protected abstract readonly regex: RegExp;
public constructor(
private readonly utilities: RegexParserUtilities = DefaultRegexParserUtilities,
) {
}
public findExpressions(code: string): IExpression[] {
return Array.from(this.findRegexExpressions(code));
}
protected abstract buildExpression(match: RegExpMatchArray): IPrimitiveExpression;
protected abstract buildExpression(match: RegExpMatchArray): PrimitiveExpression;
private* findRegexExpressions(code: string): Iterable<IExpression> {
if (!code) {
throw new Error('missing code');
throw new Error(
this.buildErrorMessageWithContext({ errorMessage: 'missing code', code: 'EMPTY' }),
);
}
const matches = code.matchAll(this.regex);
const createErrorContext = (message: string): ErrorContext => ({ code, errorMessage: message });
const matches = this.doOrRethrow(
() => code.matchAll(this.regex),
createErrorContext('Failed to match regex.'),
);
for (const match of matches) {
const primitiveExpression = this.buildExpression(match);
const position = this.doOrRethrow(() => createPositionFromRegexFullMatch(match), 'invalid script position', code);
const parameters = createParameters(primitiveExpression);
const expression = new Expression(position, primitiveExpression.evaluator, parameters);
const primitiveExpression = this.doOrRethrow(
() => this.buildExpression(match),
createErrorContext('Failed to build expression.'),
);
const position = this.doOrRethrow(
() => this.utilities.createPosition(match),
createErrorContext('Failed to create position.'),
);
const parameters = this.doOrRethrow(
() => createParameters(
primitiveExpression,
this.utilities.createParameterCollection(),
),
createErrorContext('Failed to create parameters.'),
);
const expression = this.doOrRethrow(
() => this.utilities.createExpression({
position,
evaluator: primitiveExpression.evaluator,
parameters,
}),
createErrorContext('Failed to create expression.'),
);
yield expression;
}
}
private doOrRethrow<T>(action: () => T, errorText: string, code: string): T {
private doOrRethrow<T>(
action: () => T,
context: ErrorContext,
): T {
try {
return action();
} catch (error) {
throw new Error(`[${this.constructor.name}] ${errorText}: ${error.message}\nRegex: ${this.regex}\nCode: ${code}`);
throw this.utilities.wrapError(
error,
this.buildErrorMessageWithContext(context),
);
}
}
private buildErrorMessageWithContext(context: ErrorContext): string {
return [
context.errorMessage,
`Class name: ${this.constructor.name}`,
`Regex pattern used: ${this.regex}`,
`Code: ${context.code}`,
].join('\n');
}
}
interface ErrorContext {
readonly errorMessage: string,
readonly code: string,
}
function createParameters(
expression: IPrimitiveExpression,
): FunctionParameterCollection {
expression: PrimitiveExpression,
parameterCollection: IFunctionParameterCollection,
): IReadOnlyFunctionParameterCollection {
return (expression.parameters || [])
.reduce((parameters, parameter) => {
parameters.addParameter(parameter);
return parameters;
}, new FunctionParameterCollection());
}, parameterCollection);
}
export interface IPrimitiveExpression {
evaluator: ExpressionEvaluator;
parameters?: readonly IFunctionParameter[];
export interface PrimitiveExpression {
readonly evaluator: ExpressionEvaluator;
readonly parameters?: readonly IFunctionParameter[];
}
export interface ExpressionFactory {
(
...args: ConstructorParameters<typeof Expression>
): IExpression;
}
const DefaultRegexParserUtilities: RegexParserUtilities = {
wrapError: wrapErrorWithAdditionalContext,
createPosition: createPositionFromRegexFullMatch,
createExpression: (...args) => new Expression(...args),
createParameterCollection: createFunctionParameterCollection,
};

View File

@@ -1,5 +1,5 @@
import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter';
import { RegexParser, type IPrimitiveExpression } from '../Parser/Regex/RegexParser';
import { RegexParser, type PrimitiveExpression } from '../Parser/Regex/RegexParser';
import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder';
export class ParameterSubstitutionParser extends RegexParser {
@@ -12,7 +12,7 @@ export class ParameterSubstitutionParser extends RegexParser {
.expectExpressionEnd()
.buildRegExp();
protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression {
protected buildExpression(match: RegExpMatchArray): PrimitiveExpression {
const parameterName = match[1];
const pipeline = match[2];
return {

View File

@@ -8,7 +8,7 @@ export class FunctionCallArgument implements IFunctionCallArgument {
) {
ensureValidParameterName(parameterName);
if (!argumentValue) {
throw new Error(`missing argument value for "${parameterName}"`);
throw new Error(`Missing argument value for the parameter "${parameterName}".`);
}
}
}

View File

@@ -72,7 +72,7 @@ function throwIfUnexpectedParametersExist(
// eslint-disable-next-line prefer-template
`Function "${functionName}" has unexpected parameter(s) provided: `
+ `"${unexpectedParameters.join('", "')}"`
+ '. Expected parameter(s): '
+ (expectedParameters.length ? `"${expectedParameters.join('", "')}"` : 'none'),
+ '.\nExpected parameter(s): '
+ (expectedParameters.length ? `"${expectedParameters.join('", "')}".` : 'none'),
);
}

View File

@@ -6,11 +6,14 @@ import type { IExpressionsCompiler } from '@/application/Parser/Script/Compiler/
import type { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall';
import type { FunctionCallCompilationContext } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompilationContext';
import { ParsedFunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/ParsedFunctionCall';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import type { ArgumentCompiler } from './ArgumentCompiler';
export class NestedFunctionArgumentCompiler implements ArgumentCompiler {
constructor(
private readonly expressionsCompiler: IExpressionsCompiler = new ExpressionsCompiler(),
private readonly wrapError: ErrorWithContextWrapper
= wrapErrorWithAdditionalContext,
) { }
public createCompiledNestedCall(
@@ -22,18 +25,26 @@ export class NestedFunctionArgumentCompiler implements ArgumentCompiler {
nestedFunction,
parentFunction.args,
context,
this.expressionsCompiler,
{
expressionsCompiler: this.expressionsCompiler,
wrapError: this.wrapError,
},
);
const compiledCall = new ParsedFunctionCall(nestedFunction.functionName, compiledArgs);
return compiledCall;
}
}
interface ArgumentCompilationUtilities {
readonly expressionsCompiler: IExpressionsCompiler,
readonly wrapError: ErrorWithContextWrapper;
}
function compileNestedFunctionArguments(
nestedFunction: FunctionCall,
parentFunctionArgs: IReadOnlyFunctionCallArgumentCollection,
context: FunctionCallCompilationContext,
expressionsCompiler: IExpressionsCompiler,
utilities: ArgumentCompilationUtilities,
): IReadOnlyFunctionCallArgumentCollection {
const requiredParameterNames = context
.allFunctions
@@ -47,7 +58,7 @@ function compileNestedFunctionArguments(
paramName,
nestedFunction,
parentFunctionArgs,
expressionsCompiler,
utilities,
),
}))
// Filter out arguments with absent values
@@ -89,13 +100,13 @@ function compileArgument(
parameterName: string,
nestedFunction: FunctionCall,
parentFunctionArgs: IReadOnlyFunctionCallArgumentCollection,
expressionsCompiler: IExpressionsCompiler,
utilities: ArgumentCompilationUtilities,
): string {
try {
const { argumentValue: codeInArgument } = nestedFunction.args.getArgument(parameterName);
return expressionsCompiler.compileExpressions(codeInArgument, parentFunctionArgs);
} catch (err) {
throw new AggregateError([err], `Error when compiling argument for "${parameterName}"`);
return utilities.expressionsCompiler.compileExpressions(codeInArgument, parentFunctionArgs);
} catch (error) {
throw utilities.wrapError(error, `Error when compiling argument for "${parameterName}"`);
}
}

View File

@@ -1,14 +1,21 @@
import { type CallFunctionBody, FunctionBodyType, type ISharedFunction } from '@/application/Parser/Script/Compiler/Function/ISharedFunction';
import {
type CallFunctionBody, FunctionBodyType,
type ISharedFunction,
} from '@/application/Parser/Script/Compiler/Function/ISharedFunction';
import type { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall';
import type { FunctionCallCompilationContext } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompilationContext';
import type { CompiledCode } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/CompiledCode';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { NestedFunctionArgumentCompiler } from './Argument/NestedFunctionArgumentCompiler';
import type { SingleCallCompilerStrategy } from '../SingleCallCompilerStrategy';
import type { ArgumentCompiler } from './Argument/ArgumentCompiler';
export class NestedFunctionCallCompiler implements SingleCallCompilerStrategy {
public constructor(
private readonly argumentCompiler: ArgumentCompiler = new NestedFunctionArgumentCompiler(),
private readonly argumentCompiler: ArgumentCompiler
= new NestedFunctionArgumentCompiler(),
private readonly wrapError: ErrorWithContextWrapper
= wrapErrorWithAdditionalContext,
) {
}
@@ -29,8 +36,11 @@ export class NestedFunctionCallCompiler implements SingleCallCompilerStrategy {
const compiledNestedCall = context.singleCallCompiler
.compileSingleCall(compiledParentCall, context);
return compiledNestedCall;
} catch (err) {
throw new AggregateError([err], `Error with call to "${nestedCall.functionName}" function from "${callToFunction.functionName}" function`);
} catch (error) {
throw this.wrapError(
error,
`Failed to call '${nestedCall.functionName}' (callee function) from '${callToFunction.functionName}' (caller function).`,
);
}
}).flat();
}

View File

@@ -0,0 +1,12 @@
import { FunctionParameterCollection } from './FunctionParameterCollection';
import type { IFunctionParameterCollection } from './IFunctionParameterCollection';
export interface FunctionParameterCollectionFactory {
(
...args: ConstructorParameters<typeof FunctionParameterCollection>
): IFunctionParameterCollection;
}
export const createFunctionParameterCollection: FunctionParameterCollectionFactory = (...args) => {
return new FunctionParameterCollection(...args);
};

View File

@@ -15,7 +15,7 @@ export class SharedFunctionCollection implements ISharedFunctionCollection {
if (!name) { throw Error('missing function name'); }
const func = this.functionsByName.get(name);
if (!func) {
throw new Error(`called function is not defined "${name}"`);
throw new Error(`Called function is not defined: "${name}"`);
}
return func;
}

View File

@@ -1,5 +1,6 @@
import type {
FunctionData, CodeInstruction, CodeFunctionData, CallFunctionData, CallInstruction,
FunctionData, CodeInstruction, CodeFunctionData, CallFunctionData,
CallInstruction, ParameterDefinitionData,
} from '@/application/collections/';
import type { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValidator';
@@ -7,20 +8,30 @@ import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmp
import { NoDuplicatedLines } from '@/application/Parser/Script/Validation/Rules/NoDuplicatedLines';
import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator';
import { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { createFunctionWithInlineCode, createCallerFunction } from './SharedFunction';
import { SharedFunctionCollection } from './SharedFunctionCollection';
import { FunctionParameter } from './Parameter/FunctionParameter';
import { FunctionParameterCollection } from './Parameter/FunctionParameterCollection';
import { parseFunctionCalls } from './Call/FunctionCallParser';
import { createFunctionParameterCollection, type FunctionParameterCollectionFactory } from './Parameter/FunctionParameterCollectionFactory';
import type { ISharedFunctionCollection } from './ISharedFunctionCollection';
import type { ISharedFunctionsParser } from './ISharedFunctionsParser';
import type { IReadOnlyFunctionParameterCollection } from './Parameter/IFunctionParameterCollection';
import type { ISharedFunction } from './ISharedFunction';
const DefaultSharedFunctionsParsingUtilities: SharedFunctionsParsingUtilities = {
wrapError: wrapErrorWithAdditionalContext,
createParameter: (...args) => new FunctionParameter(...args),
codeValidator: CodeValidator.instance,
createParameterCollection: createFunctionParameterCollection,
};
export class SharedFunctionsParser implements ISharedFunctionsParser {
public static readonly instance: ISharedFunctionsParser = new SharedFunctionsParser();
constructor(private readonly codeValidator: ICodeValidator = CodeValidator.instance) { }
constructor(
private readonly utilities = DefaultSharedFunctionsParsingUtilities,
) { }
public parseFunctions(
functions: readonly FunctionData[],
@@ -32,7 +43,7 @@ export class SharedFunctionsParser implements ISharedFunctionsParser {
}
ensureValidFunctions(functions);
return functions
.map((func) => parseFunction(func, syntax, this.codeValidator))
.map((func) => parseFunction(func, syntax, this.utilities))
.reduce((acc, func) => {
acc.addFunction(func);
return acc;
@@ -40,15 +51,26 @@ export class SharedFunctionsParser implements ISharedFunctionsParser {
}
}
interface SharedFunctionsParsingUtilities {
readonly wrapError: ErrorWithContextWrapper;
readonly createParameter: FunctionParameterFactory;
readonly codeValidator: ICodeValidator;
readonly createParameterCollection: FunctionParameterCollectionFactory;
}
export type FunctionParameterFactory = (
...args: ConstructorParameters<typeof FunctionParameter>
) => FunctionParameter;
function parseFunction(
data: FunctionData,
syntax: ILanguageSyntax,
validator: ICodeValidator,
utilities: SharedFunctionsParsingUtilities,
): ISharedFunction {
const { name } = data;
const parameters = parseParameters(data);
const parameters = parseParameters(data, utilities);
if (hasCode(data)) {
validateCode(data, syntax, validator);
validateCode(data, syntax, utilities.codeValidator);
return createFunctionWithInlineCode(name, parameters, data.code, data.revertCode);
}
// Has call
@@ -71,22 +93,38 @@ function validateCode(
);
}
function parseParameters(data: FunctionData): IReadOnlyFunctionParameterCollection {
function parseParameters(
data: FunctionData,
utilities: SharedFunctionsParsingUtilities,
): IReadOnlyFunctionParameterCollection {
return (data.parameters || [])
.map((parameter) => {
try {
return new FunctionParameter(
parameter.name,
parameter.optional || false,
);
} catch (err) {
throw new Error(`"${data.name}": ${err.message}`);
}
})
.map((parameter) => createFunctionParameter(
data.name,
parameter,
utilities,
))
.reduce((parameters, parameter) => {
parameters.addParameter(parameter);
return parameters;
}, new FunctionParameterCollection());
}, utilities.createParameterCollection());
}
function createFunctionParameter(
functionName: string,
parameterData: ParameterDefinitionData,
utilities: SharedFunctionsParsingUtilities,
): FunctionParameter {
try {
return utilities.createParameter(
parameterData.name,
parameterData.optional || false,
);
} catch (err) {
throw utilities.wrapError(
err,
`Failed to create parameter: ${parameterData.name} for function "${functionName}"`,
);
}
}
function hasCode(data: FunctionData): data is CodeFunctionData {
@@ -98,6 +136,7 @@ function hasCall(data: FunctionData): data is CallFunctionData {
}
function ensureValidFunctions(functions: readonly FunctionData[]) {
ensureNoUnnamedFunctions(functions);
ensureNoDuplicatesInFunctionNames(functions);
ensureEitherCallOrCodeIsDefined(functions);
ensureNoDuplicateCode(functions);
@@ -108,6 +147,16 @@ function printList(list: readonly string[]): string {
return `"${list.join('","')}"`;
}
function ensureNoUnnamedFunctions(functions: readonly FunctionData[]) {
const functionsWithoutNames = functions.filter(
(func) => !func.name || func.name.trim().length === 0,
);
if (functionsWithoutNames.length) {
const invalidFunctions = functionsWithoutNames.map((f) => JSON.stringify(f));
throw new Error(`Some function(s) have no names:\n${invalidFunctions.join('\n')}`);
}
}
function ensureEitherCallOrCodeIsDefined(holders: readonly FunctionData[]) {
// Ensure functions do not define both call and code
const withBothCallAndCode = holders.filter((holder) => hasCode(holder) && hasCall(holder));

View File

@@ -1,10 +1,11 @@
import type { FunctionData, ScriptData, CallInstruction } from '@/application/collections/';
import type { IScriptCode } from '@/domain/IScriptCode';
import { ScriptCode } from '@/domain/ScriptCode';
import type { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValidator';
import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines';
import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { createScriptCode, type ScriptCodeFactory } from '@/domain/ScriptCodeFactory';
import { SharedFunctionsParser } from './Function/SharedFunctionsParser';
import { FunctionCallSequenceCompiler } from './Function/Call/Compiler/FunctionCallSequenceCompiler';
import { parseFunctionCalls } from './Function/Call/FunctionCallParser';
@@ -23,6 +24,8 @@ export class ScriptCompiler implements IScriptCompiler {
sharedFunctionsParser: ISharedFunctionsParser = SharedFunctionsParser.instance,
private readonly callCompiler: FunctionCallCompiler = FunctionCallSequenceCompiler.instance,
private readonly codeValidator: ICodeValidator = CodeValidator.instance,
private readonly wrapError: ErrorWithContextWrapper = wrapErrorWithAdditionalContext,
private readonly scriptCodeFactory: ScriptCodeFactory = createScriptCode,
) {
this.functions = sharedFunctionsParser.parseFunctions(functions, syntax);
}
@@ -39,12 +42,12 @@ export class ScriptCompiler implements IScriptCompiler {
const calls = parseFunctionCalls(script.call);
const compiledCode = this.callCompiler.compileFunctionCalls(calls, this.functions);
validateCompiledCode(compiledCode, this.codeValidator);
return new ScriptCode(
return this.scriptCodeFactory(
compiledCode.code,
compiledCode.revertCode,
);
} catch (error) {
throw Error(`Script "${script.name}" ${error.message}`);
throw this.wrapError(error, `Failed to compile script: ${script.name}`);
}
}
}

View File

@@ -4,37 +4,52 @@ import type { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syn
import { Script } from '@/domain/Script';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
import type { IScriptCode } from '@/domain/IScriptCode';
import { ScriptCode } from '@/domain/ScriptCode';
import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator';
import { parseDocs } from '../DocumentationParser';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import type { ScriptCodeFactory } from '@/domain/ScriptCodeFactory';
import { createScriptCode } from '@/domain/ScriptCodeFactory';
import type { IScript } from '@/domain/IScript';
import { parseDocs, type DocsParser } from '../DocumentationParser';
import { createEnumParser, type IEnumParser } from '../../Common/Enum';
import { NodeType } from '../NodeValidation/NodeType';
import { NodeValidator } from '../NodeValidation/NodeValidator';
import { NodeDataType } from '../NodeValidation/NodeDataType';
import { createNodeDataValidator, type NodeDataValidator, type NodeDataValidatorFactory } from '../NodeValidation/NodeDataValidator';
import { CodeValidator } from './Validation/CodeValidator';
import { NoDuplicatedLines } from './Validation/Rules/NoDuplicatedLines';
import type { ICategoryCollectionParseContext } from './ICategoryCollectionParseContext';
export function parseScript(
data: ScriptData,
context: ICategoryCollectionParseContext,
levelParser = createEnumParser(RecommendationLevel),
scriptFactory: ScriptFactoryType = ScriptFactory,
codeValidator: ICodeValidator = CodeValidator.instance,
): Script {
const validator = new NodeValidator({ type: NodeType.Script, selfNode: data });
export interface ScriptParser {
(
data: ScriptData,
context: ICategoryCollectionParseContext,
utilities?: ScriptParserUtilities,
): IScript;
}
export const parseScript: ScriptParser = (
data,
context,
utilities = DefaultScriptParserUtilities,
) => {
const validator = utilities.createValidator({
type: NodeDataType.Script,
selfNode: data,
});
validateScript(data, validator);
try {
const script = scriptFactory(
/* name: */ data.name,
/* code: */ parseCode(data, context, codeValidator),
/* docs: */ parseDocs(data),
/* level: */ parseLevel(data.recommend, levelParser),
);
const script = utilities.createScript({
name: data.name,
code: parseCode(data, context, utilities.codeValidator, utilities.createCode),
docs: utilities.parseDocs(data),
level: parseLevel(data.recommend, utilities.levelParser),
});
return script;
} catch (err) {
return validator.throw(err.message);
} catch (error) {
throw utilities.wrapError(
error,
validator.createContextualErrorMessage('Failed to parse script.'),
);
}
}
};
function parseLevel(
level: string | undefined,
@@ -50,18 +65,19 @@ function parseCode(
script: ScriptData,
context: ICategoryCollectionParseContext,
codeValidator: ICodeValidator,
createCode: ScriptCodeFactory,
): IScriptCode {
if (context.compiler.canCompile(script)) {
return context.compiler.compile(script);
}
const codeScript = script as CodeScriptData; // Must be inline code if it cannot be compiled
const code = new ScriptCode(codeScript.code, codeScript.revertCode);
const code = createCode(codeScript.code, codeScript.revertCode);
validateHardcodedCodeWithoutCalls(code, codeValidator, context.syntax);
return code;
}
function validateHardcodedCodeWithoutCalls(
scriptCode: ScriptCode,
scriptCode: IScriptCode,
validator: ICodeValidator,
syntax: ILanguageSyntax,
) {
@@ -77,25 +93,48 @@ function validateHardcodedCodeWithoutCalls(
function validateScript(
script: ScriptData,
validator: NodeValidator,
validator: NodeDataValidator,
): asserts script is NonNullable<ScriptData> {
validator
.assertDefined(script)
.assertValidName(script.name)
.assert(
() => Boolean((script as CodeScriptData).code || (script as CallScriptData).call),
'Neither "call" or "code" is defined.',
)
.assert(
() => !((script as CodeScriptData).code && (script as CallScriptData).call),
'Both "call" and "code" are defined.',
)
.assert(
() => !((script as CodeScriptData).revertCode && (script as CallScriptData).call),
'Both "call" and "revertCode" are defined.',
);
validator.assertDefined(script);
validator.assertValidName(script.name);
validator.assert(
() => Boolean((script as CodeScriptData).code || (script as CallScriptData).call),
'Neither "call" or "code" is defined.',
);
validator.assert(
() => !((script as CodeScriptData).code && (script as CallScriptData).call),
'Both "call" and "code" are defined.',
);
validator.assert(
() => !((script as CodeScriptData).revertCode && (script as CallScriptData).call),
'Both "call" and "revertCode" are defined.',
);
}
export type ScriptFactoryType = (...parameters: ConstructorParameters<typeof Script>) => Script;
interface ScriptParserUtilities {
readonly levelParser: IEnumParser<RecommendationLevel>;
readonly createScript: ScriptFactory;
readonly codeValidator: ICodeValidator;
readonly wrapError: ErrorWithContextWrapper;
readonly createValidator: NodeDataValidatorFactory;
readonly createCode: ScriptCodeFactory;
readonly parseDocs: DocsParser;
}
const ScriptFactory: ScriptFactoryType = (...parameters) => new Script(...parameters);
export type ScriptFactory = (
...parameters: ConstructorParameters<typeof Script>
) => IScript;
const createScript: ScriptFactory = (...parameters) => {
return new Script(...parameters);
};
const DefaultScriptParserUtilities: ScriptParserUtilities = {
levelParser: createEnumParser(RecommendationLevel),
createScript,
codeValidator: CodeValidator.instance,
wrapError: wrapErrorWithAdditionalContext,
createValidator: createNodeDataValidator,
createCode: createScriptCode,
parseDocs,
};

View File

@@ -5,6 +5,7 @@ import { ExpressionsCompiler } from '@/application/Parser/Script/Compiler/Expres
import type { ProjectDetails } from '@/domain/Project/ProjectDetails';
import { FunctionCallArgumentCollection } from '@/application/Parser/Script/Compiler/Function/Call/Argument/FunctionCallArgumentCollection';
import { FunctionCallArgument } from '@/application/Parser/Script/Compiler/Function/Call/Argument/FunctionCallArgument';
import type { IExpressionParser } from '@/application/Parser/Script/Compiler/Expressions/Parser/IExpressionParser';
import type { ICodeSubstituter } from './ICodeSubstituter';
export class CodeSubstituter implements ICodeSubstituter {
@@ -29,7 +30,9 @@ export class CodeSubstituter implements ICodeSubstituter {
}
function createSubstituteCompiler(): IExpressionsCompiler {
const parsers = [new ParameterSubstitutionParser()];
const parsers: readonly IExpressionParser[] = [
new ParameterSubstitutionParser(),
] as const;
const parser = new CompositeExpressionParser(parsers);
const expressionCompiler = new ExpressionsCompiler(parser);
return expressionCompiler;

View File

@@ -2072,8 +2072,8 @@ actions:
[1]: https://web.archive.org/web/20221029165307/https://packages.fedoraproject.org/pkgs/zeitgeist/zeitgeist/index.html "zeitgeist - Fedora Packages | packages.fedoraproject.org"
[2]: https://web.archive.org/web/20221029165603/https://archlinux.org/packages/extra/x86_64/zeitgeist/ "Arch Linux - zeitgeist 1.0.4-1 (x86_64) | archlinux.org"
[3]: https://web.archive.org/web/20221029165609/https://packages.debian.org/search?keywords=zeitgeist-core "Debian -- Package Search Results -- zeitgeist-core | packages.debian.org"
[4]: https://web.archive.org/web/20221029165714/https://releases.ubuntu.com/xenial/ubuntu-16.04.6-desktop-i386.manifest "List of sofware packags shipped with Ubuntu 16.04.6 | releases.ubuntu.com"
[5]: https://web.archive.org/web/20221029165726/https://releases.ubuntu.com/18.04/ubuntu-18.04.6-desktop-amd64.manifest "List of sofware packags shipped with Ubuntu 18.04.6 | releases.ubuntu.com"
[4]: https://web.archive.org/web/20221029165714/https://releases.ubuntu.com/xenial/ubuntu-16.04.6-desktop-i386.manifest "List of software packages shipped with Ubuntu 16.04.6 | releases.ubuntu.com"
[5]: https://web.archive.org/web/20221029165726/https://releases.ubuntu.com/18.04/ubuntu-18.04.6-desktop-amd64.manifest "List of software packages shipped with Ubuntu 18.04.6 | releases.ubuntu.com"
[6]: https://web.archive.org/web/20221029165821/https://bugs.archlinux.org/task/52326 "FS#52326 : [midori-gtk2] Please remove the zeitgeist dependency! | archlinux.org"
[7]: https://web.archive.org/web/20221029165914/https://forum.artixlinux.org/index.php/topic,1432.0.html "Remove Unmaintained Zeitgeist (Spyware/Telemetry) from Default MATE installation | artixlinux.org"
[8]: https://web.archive.org/web/20221029165902/https://askubuntu.com/questions/45548/disabling-zeitgeist/57487 "Disabling Zeitgeist - Ask Ubuntu | askubuntu.com"

View File

@@ -1408,7 +1408,7 @@ actions:
name: Disable Gatekeeper
docs:
# References for spctl --master-disable
- https://www.manpagez.com/man/8/spctl/
- https://web.archive.org/web/20240523173608/https://www.manpagez.com/man/8/spctl/
# References for /var/db/SystemPolicy-prefs.plist
- https://krypted.com/mac-security/manage-gatekeeper-from-the-command-line-in-mountain-lion/
- https://community.jamf.com/t5/jamf-pro/users-can-t-change-password-greyed-out/m-p/54228

File diff suppressed because it is too large Load Diff

View File

@@ -5,15 +5,21 @@ import type { IScript } from './IScript';
export class Category extends BaseEntity<number> implements ICategory {
private allSubScripts?: ReadonlyArray<IScript> = undefined;
constructor(
id: number,
public readonly name: string,
public readonly docs: ReadonlyArray<string>,
public readonly subCategories: ReadonlyArray<ICategory>,
public readonly scripts: ReadonlyArray<IScript>,
) {
super(id);
validateCategory(this);
public readonly name: string;
public readonly docs: ReadonlyArray<string>;
public readonly subCategories: ReadonlyArray<ICategory>;
public readonly scripts: ReadonlyArray<IScript>;
constructor(parameters: CategoryInitParameters) {
super(parameters.id);
validateParameters(parameters);
this.name = parameters.name;
this.docs = parameters.docs;
this.subCategories = parameters.subcategories;
this.scripts = parameters.scripts;
}
public includes(script: IScript): boolean {
@@ -28,6 +34,14 @@ export class Category extends BaseEntity<number> implements ICategory {
}
}
export interface CategoryInitParameters {
readonly id: number;
readonly name: string;
readonly docs: ReadonlyArray<string>;
readonly subcategories: ReadonlyArray<ICategory>;
readonly scripts: ReadonlyArray<IScript>;
}
function parseScriptsRecursively(category: ICategory): ReadonlyArray<IScript> {
return [
...category.scripts,
@@ -35,11 +49,11 @@ function parseScriptsRecursively(category: ICategory): ReadonlyArray<IScript> {
];
}
function validateCategory(category: ICategory) {
if (!category.name) {
function validateParameters(parameters: CategoryInitParameters) {
if (!parameters.name) {
throw new Error('missing name');
}
if (category.subCategories.length === 0 && category.scripts.length === 0) {
if (parameters.subcategories.length === 0 && parameters.scripts.length === 0) {
throw new Error('A category must have at least one sub-category or script');
}
}

View File

@@ -4,14 +4,21 @@ import type { IScript } from './IScript';
import type { IScriptCode } from './IScriptCode';
export class Script extends BaseEntity<string> implements IScript {
constructor(
public readonly name: string,
public readonly code: IScriptCode,
public readonly docs: ReadonlyArray<string>,
public readonly level?: RecommendationLevel,
) {
super(name);
validateLevel(level);
public readonly name: string;
public readonly code: IScriptCode;
public readonly docs: ReadonlyArray<string>;
public readonly level?: RecommendationLevel;
constructor(parameters: ScriptInitParameters) {
super(parameters.name);
this.name = parameters.name;
this.code = parameters.code;
this.docs = parameters.docs;
this.level = parameters.level;
validateLevel(parameters.level);
}
public canRevert(): boolean {
@@ -19,6 +26,13 @@ export class Script extends BaseEntity<string> implements IScript {
}
}
export interface ScriptInitParameters {
readonly name: string;
readonly code: IScriptCode;
readonly docs: ReadonlyArray<string>;
readonly level?: RecommendationLevel;
}
function validateLevel(level?: RecommendationLevel) {
if (level !== undefined && !(level in RecommendationLevel)) {
throw new Error(`invalid level: ${level}`);

View File

@@ -0,0 +1,10 @@
import { ScriptCode } from './ScriptCode';
import type { IScriptCode } from './IScriptCode';
export interface ScriptCodeFactory {
(
...args: ConstructorParameters<typeof ScriptCode>
): IScriptCode;
}
export const createScriptCode: ScriptCodeFactory = (...args) => new ScriptCode(...args);

View File

@@ -1,5 +1,7 @@
<template>
<span class="info-container">
<span
class="info-container"
>
<TooltipWrapper>
<AppIcon icon="circle-info" />
<template #tooltip>
@@ -19,27 +21,17 @@ export default defineComponent({
TooltipWrapper,
AppIcon,
},
props: {
hasLeftMargin: {
type: Boolean,
default: false,
},
},
});
</script>
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
@mixin apply-style-when-placed-after-non-text {
* + & {
@content;
}
}
<style lang="scss">
.info-container {
vertical-align: text-top;
* + & { // If it's followed by any other element
vertical-align: middle;
@include set-property-ch-value-with-fallback(
$property: margin-left,
$value-in-ch: 0.5,
)
}
}
</style>

View File

@@ -0,0 +1,36 @@
<template>
<span class="info-tooltip-wrapper">
<span>
<slot />
</span>
<span>
<InfoTooltipInline>
<slot name="info" />
</InfoTooltipInline>
</span>
</span>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import InfoTooltipInline from './InfoTooltipInline.vue';
export default defineComponent({
components: {
InfoTooltipInline,
},
});
</script>
<style lang="scss">
@use "@/presentation/assets/styles/main" as *;
.info-tooltip-wrapper {
display: flex;
align-items: center;
@include set-property-ch-value-with-fallback(
$property: gap,
$value-in-ch: 0.5,
)
}
</style>

View File

@@ -17,7 +17,7 @@
<p>
This requires you to do additional manual
steps. If you are unsure how to follow the instructions, tap or hover on information
<InfoTooltip>Engage with icons like this for extra wisdom!</InfoTooltip>
<InfoTooltipInline>Engage with icons like this for extra wisdom!</InfoTooltipInline>
icons near the steps, or follow the easy alternative described above.
</p>
<p>
@@ -32,12 +32,12 @@ import { defineComponent, computed } from 'vue';
import { injectKey } from '@/presentation/injectionSymbols';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { getOperatingSystemDisplayName } from '@/presentation/components/Shared/OperatingSystemNames';
import InfoTooltip from './InfoTooltip.vue';
import InfoTooltipInline from './Help/InfoTooltipInline.vue';
import PlatformInstructionSteps from './Steps/PlatformInstructionSteps.vue';
export default defineComponent({
components: {
InfoTooltip,
InfoTooltipInline,
PlatformInstructionSteps,
},
props: {

View File

@@ -2,7 +2,7 @@
<InstructionSteps>
<InstructionStep>
Download the file.
<InfoTooltip>
<InfoTooltipInline>
<p>
You should have already been prompted to save the script file.
</p>
@@ -10,11 +10,11 @@
If this was not the case or you did not save the script when prompted,
please try to download your script file again.
</p>
</InfoTooltip>
</InfoTooltipInline>
</InstructionStep>
<InstructionStep>
Open terminal.
<InfoTooltip>
<InfoTooltipInline>
<p>
Opening terminal changes based on the distro you run.
</p>
@@ -39,30 +39,32 @@
</li>
</ul>
</p>
</InfoTooltip>
</InfoTooltipInline>
</InstructionStep>
<InstructionStep>
<p>
Navigate to the folder where you downloaded the file e.g.:
</p>
<p>
<CopyableCommand>cd ~/Downloads</CopyableCommand>
<InfoTooltip>
<p>
Press on <code>enter/return</code> key after running the command.
</p>
<p>
If the file is not downloaded on Downloads folder,
change <code>Downloads</code> to path where the file is downloaded.
</p>
<p>
This command means:
<ul>
<li><code>cd</code> will change the current folder.</li>
<li><code>~</code> is the user home directory.</li>
</ul>
</p>
</InfoTooltip>
<InfoTooltipWrapper>
<CopyableCommand>cd ~/Downloads</CopyableCommand>
<template #info>
<p>
Press on <code>enter/return</code> key after running the command.
</p>
<p>
If the file is not downloaded on Downloads folder,
change <code>Downloads</code> to path where the file is downloaded.
</p>
<p>
This command means:
<ul>
<li><code>cd</code> will change the current folder.</li>
<li><code>~</code> is the user home directory.</li>
</ul>
</p>
</template>
</InfoTooltipWrapper>
</p>
</InstructionStep>
<InstructionStep>
@@ -70,26 +72,28 @@
Give the file execute permissions:
</p>
<p>
<CopyableCommand>chmod +x {{ filename }}</CopyableCommand>
<InfoTooltip>
<p>
Press on <code>enter/return</code> key after running the command.
</p>
<p>
It will make the file executable.
</p>
<p>
If you use desktop environment you can alternatively (instead of running the command):
<ol>
<li>Locate the file using your file manager.</li>
<li>Right click on the file, select "Properties".</li>
<li>Go to "Permissions" and check "Allow executing file as program".</li>
</ol>
</p>
<p>
These GUI steps and name of options may change depending on your file manager.'
</p>
</InfoTooltip>
<InfoTooltipWrapper>
<CopyableCommand>chmod +x {{ filename }}</CopyableCommand>
<template #info>
<p>
Press on <code>enter/return</code> key after running the command.
</p>
<p>
It will make the file executable.
</p>
<p>
If you use desktop environment you can alternatively (instead of running the command):
<ol>
<li>Locate the file using your file manager.</li>
<li>Right click on the file, select "Properties".</li>
<li>Go to "Permissions" and check "Allow executing file as program".</li>
</ol>
</p>
<p>
These GUI steps and name of options may change depending on your file manager.'
</p>
</template>
</InfoTooltipWrapper>
</p>
</InstructionStep>
<InstructionStep>
@@ -97,21 +101,24 @@
Execute the file:
</p>
<p>
<CopyableCommand>./{{ filename }}</CopyableCommand>
<InfoTooltip>
<p>
If you have desktop environment, instead of running this command you can alternatively:
</p>
<ol>
<li>Locate the file using your file manager.</li>
<li>Right click on the file, select "Run as program".</li>
</ol>
</InfoTooltip>
<InfoTooltipWrapper>
<CopyableCommand>./{{ filename }}</CopyableCommand>
<template #info>
<p>
If you have desktop environment, instead of running this command
you can alternatively:
</p>
<ol>
<li>Locate the file using your file manager.</li>
<li>Right click on the file, select "Run as program".</li>
</ol>
</template>
</InfoTooltipWrapper>
</p>
</InstructionStep>
<InstructionStep>
If asked, enter your administrator password.
<InfoTooltip>
<InfoTooltipInline>
<p>
As you type, your password will be hidden but the keys are still
registered, so keep typing.
@@ -122,7 +129,7 @@
<p>
Administrator privileges are required to configure OS.
</p>
</InfoTooltip>
</InfoTooltipInline>
</InstructionStep>
</InstructionSteps>
</template>
@@ -131,13 +138,15 @@
import { defineComponent } from 'vue';
import InstructionSteps from '../InstructionSteps.vue';
import InstructionStep from '../InstructionStep.vue';
import InfoTooltip from '../../InfoTooltip.vue';
import InfoTooltipInline from '../../Help/InfoTooltipInline.vue';
import InfoTooltipWrapper from '../../Help/InfoTooltipWrapper.vue';
import CopyableCommand from '../CopyableCommand.vue';
export default defineComponent({
components: {
CopyableCommand,
InfoTooltip,
InfoTooltipInline,
InfoTooltipWrapper,
InstructionSteps,
InstructionStep,
},

View File

@@ -2,7 +2,7 @@
<InstructionSteps>
<InstructionStep>
Download the file.
<InfoTooltip>
<InfoTooltipInline>
<p>
You should have already been prompted to save the script file.
</p>
@@ -10,38 +10,38 @@
If this was not the case or you did not save the script when prompted,
please try to download your script file again.
</p>
</InfoTooltip>
</InfoTooltipInline>
</InstructionStep>
<InstructionStep>
Open terminal.
<InfoTooltip>
<InfoTooltipInline>
Type Terminal into Spotlight or open it from the Applications -> Utilities folder.
</InfoTooltip>
</InfoTooltipInline>
</InstructionStep>
<InstructionStep>
<p>
Navigate to the folder where you downloaded the file e.g.:
</p>
<p>
<CopyableCommand>
cd ~/Downloads
</CopyableCommand>
<InfoTooltip>
<p>
Press on <code>enter/return</code> key after running the command.
</p>
<p>
If the file is not downloaded on Downloads folder,
change <code>Downloads</code> to path where the file is downloaded.
</p>
<p>
This command means:
<ul>
<li><code>cd</code> will change the current folder.</li>
<li><code>~</code> is the user home directory.</li>
</ul>
</p>
</InfoTooltip>
<InfoTooltipWrapper>
<CopyableCommand>cd ~/Downloads</CopyableCommand>
<template #info>
<p>
Press on <code>enter/return</code> key after running the command.
</p>
<p>
If the file is not downloaded on Downloads folder,
change <code>Downloads</code> to path where the file is downloaded.
</p>
<p>
This command means:
<ul>
<li><code>cd</code> will change the current folder.</li>
<li><code>~</code> is the user home directory.</li>
</ul>
</p>
</template>
</InfoTooltipWrapper>
</p>
</InstructionStep>
<InstructionStep>
@@ -49,15 +49,17 @@
Give the file execute permissions:
</p>
<p>
<CopyableCommand>chmod +x {{ filename }}</CopyableCommand>
<InfoTooltip>
<p>
Press on <code>enter/return</code> key after running the command.
</p>
<p>
It will make the file executable.
</p>
</InfoTooltip>
<InfoTooltipWrapper>
<CopyableCommand>chmod +x {{ filename }}</CopyableCommand>
<template #info>
<p>
Press on <code>enter/return</code> key after running the command.
</p>
<p>
It will make the file executable.
</p>
</template>
</InfoTooltipWrapper>
</p>
</InstructionStep>
<InstructionStep>
@@ -65,15 +67,17 @@
Execute the file:
</p>
<p>
<CopyableCommand>./{{ filename }}</CopyableCommand>
<InfoTooltip>
Alternatively you can locate the file in <strong>Finder</strong> and double click on it.
</InfoTooltip>
<InfoTooltipWrapper>
<CopyableCommand>./{{ filename }}</CopyableCommand>
<template #info>
Alternatively you can locate the file in <strong>Finder</strong> and double click on it.
</template>
</InfoTooltipWrapper>
</p>
</InstructionStep>
<InstructionStep>
If asked, enter your administrator password.
<InfoTooltip>
<InfoTooltipInline>
<p>
As you type, your password will be hidden but the keys are
still registered, so keep typing.
@@ -84,7 +88,7 @@
<p>
Administrator privileges are required to configure OS.
</p>
</InfoTooltip>
</InfoTooltipInline>
</InstructionStep>
</InstructionSteps>
</template>
@@ -93,13 +97,15 @@
import { defineComponent } from 'vue';
import InstructionSteps from '../InstructionSteps.vue';
import InstructionStep from '../InstructionStep.vue';
import InfoTooltip from '../../InfoTooltip.vue';
import InfoTooltipInline from '../../Help/InfoTooltipInline.vue';
import InfoTooltipWrapper from '../../Help/InfoTooltipWrapper.vue';
import CopyableCommand from '../CopyableCommand.vue';
export default defineComponent({
components: {
CopyableCommand,
InfoTooltip,
InfoTooltipInline,
InfoTooltipWrapper,
InstructionSteps,
InstructionStep,
},

View File

@@ -2,13 +2,13 @@
<InstructionSteps>
<InstructionStep>
Download the file.
<InfoTooltip>
<InfoTooltipInline>
<p>If a save prompt doesn't appear, try downloading the script again.</p>
</InfoTooltip>
</InfoTooltipInline>
</InstructionStep>
<InstructionStep>
If warned by your browser, keep the file.
<InfoTooltip>
<InfoTooltipInline>
<!--
Tests (15/01/2023):
- Edge (Defender activated): "filename isn't commonly downloaded..."
@@ -33,11 +33,11 @@
For <strong>Firefox</strong> and <strong>Chrome</strong>, typically no additional
action is needed.
</p>
</InfoTooltip>
</InfoTooltipInline>
</InstructionStep>
<InstructionStep>
If your antivirus (e.g., Defender) alerts you, address the warning.
<InfoTooltip>
<InfoTooltipInline>
<!--
Tests (15/01/2023):
- Edge (Defender activated): "Couldn't download - Virus detected"
@@ -75,7 +75,7 @@
<li>and keep real-time protection enabled whenever possible.</li>
</ul>
</blockquote>
</InfoTooltip>
</InfoTooltipInline>
</InstructionStep>
<InstructionStep>
<!--
@@ -85,7 +85,7 @@
- Firefox: "filename is executable file. Executable files may contain..?" OK/Cancel
-->
Open the downloaded file.
<InfoTooltip>
<InfoTooltipInline>
<p>
Confirm any browser prompts to open the file.
</p>
@@ -100,11 +100,11 @@
<strong>Edge</strong> and <strong>Chrome</strong> users usually will not
encounter additional prompts.
</p>
</InfoTooltip>
</InfoTooltipInline>
</InstructionStep>
<InstructionStep>
If prompted, confirm SmartScreen warnings.
<InfoTooltip>
<InfoTooltipInline>
<p>
Windows SmartScreen might display a cautionary message.
</p>
@@ -118,11 +118,11 @@
<li>Select <strong>Run anyway</strong>.</li>
</ol>
</p>
</InfoTooltip>
</InfoTooltipInline>
</InstructionStep>
<InstructionStep>
If administrative permissions are requested, grant them.
<InfoTooltip>
<InfoTooltipInline>
<p>
The script may request administrative rights to apply changes.
</p>
@@ -132,7 +132,7 @@
<p>
Click <strong>Yes</strong> to authorize and run the script.
</p>
</InfoTooltip>
</InfoTooltipInline>
</InstructionStep>
</InstructionSteps>
</template>
@@ -141,11 +141,11 @@
import { defineComponent } from 'vue';
import InstructionSteps from '../InstructionSteps.vue';
import InstructionStep from '../InstructionStep.vue';
import InfoTooltip from '../../InfoTooltip.vue';
import InfoTooltipInline from '../../Help/InfoTooltipInline.vue';
export default defineComponent({
components: {
InfoTooltip,
InfoTooltipInline,
InstructionSteps,
InstructionStep,
},

View File

@@ -1,8 +1,9 @@
import {
computed, readonly, ref, watch,
computed, readonly, ref, shallowRef, watch,
} from 'vue';
import { throttle } from '@/application/Common/Timing/Throttle';
import { useAutoUnsubscribedEventListener } from '../Shared/Hooks/UseAutoUnsubscribedEventListener';
import { useResizeObserver } from '../Shared/Hooks/Resize/UseResizeObserver';
const RESIZE_EVENT_THROTTLE_MS = 200;
@@ -29,11 +30,17 @@ function getScrollbarGutterWidth(): number {
function useBodyWidth() {
const width = ref(document.body.offsetWidth);
const observer = new ResizeObserver((entries) => throttle(() => {
for (const entry of entries) {
width.value = entry.borderBoxSize[0].inlineSize;
}
}, RESIZE_EVENT_THROTTLE_MS));
observer.observe(document.body, { box: 'border-box' });
useResizeObserver(
{
observedElementRef: shallowRef(document.body),
throttleInMs: RESIZE_EVENT_THROTTLE_MS,
observeCallback: (entries) => {
for (const entry of entries) {
width.value = entry.borderBoxSize[0].inlineSize;
}
},
observeOptions: { box: 'border-box' },
},
);
return readonly(width);
}

View File

@@ -0,0 +1,91 @@
<template>
<div>
<CardExpansionArrow />
<div class="card__expander">
<div class="card__expander__close-button">
<FlatButton
icon="xmark"
@click="collapse()"
/>
</div>
<div class="card__expander__content">
<ScriptsTree
:category-id="categoryId"
:has-top-padding="false"
/>
</div>
</div>
</div>
</template>
<script lang="ts">
import {
defineComponent,
} from 'vue';
import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
import CardExpansionArrow from './CardExpansionArrow.vue';
export default defineComponent({
components: {
ScriptsTree,
FlatButton,
CardExpansionArrow,
},
props: {
categoryId: {
type: Number,
required: true,
},
},
emits: {
/* eslint-disable @typescript-eslint/no-unused-vars */
onCollapse: () => true,
/* eslint-enable @typescript-eslint/no-unused-vars */
},
setup(_, { emit }) {
function collapse() {
emit('onCollapse');
}
return {
collapse,
};
},
});
</script>
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
@use "./card-gap" as *;
.card__expander {
position: relative;
background-color: $color-primary-darker;
color: $color-on-primary;
margin-top: $spacing-absolute-xx-large;
display: flex;
align-items: center;
flex-direction: column;
.card__expander__content {
display: flex;
justify-content: center;
word-break: break-word;
max-width: 100%; // Prevents horizontal expansion of inner content (e.g., when a code block is shown)
width: 100%; // Expands the container to fill available horizontal space, enabling alignment of child items.
}
.card__expander__close-button {
font-size: $font-size-absolute-large;
align-self: flex-end;
margin-right: $spacing-absolute-small;
@include clickable;
color: $color-primary-light;
@include hover-or-touch {
color: $color-primary;
}
}
}
</style>

View File

@@ -7,9 +7,9 @@
<!-- <div id="responsivity-debug">
Width: {{ width || 'undefined' }}
Size:
<span v-if="width <= 500">small</span>
<span v-if="width > 500 && width < 750">medium</span>
<span v-if="width >= 750">big</span>
<span v-if="width <= 500">small</span>
<span v-if="width > 500 && width < 750">medium</span>
<span v-if="width >= 750">big</span>
</div> -->
<div
v-if="categoryIds.length > 0"
@@ -27,6 +27,7 @@
:data-category="categoryId"
:category-id="categoryId"
:active-category-id="activeCategoryId"
:card-layout="cardLayout"
@card-expansion-changed="onSelected(categoryId, $event)"
/>
</div>
@@ -46,6 +47,7 @@ import { injectKey } from '@/presentation/injectionSymbols';
import SizeObserver from '@/presentation/components/Shared/SizeObserver.vue';
import { hasDirective } from './NonCollapsingDirective';
import CardListItem from './CardListItem.vue';
import { useCardLayout } from './UseCardLayout';
export default defineComponent({
components: {
@@ -61,8 +63,14 @@ export default defineComponent({
const categoryIds = computed<readonly number[]>(
() => currentState.value.collection.actions.map((category) => category.id),
);
const activeCategoryId = ref<number | undefined>(undefined);
const cardLayout = useCardLayout({
containerWidth: computed(() => width.value ?? 0),
totalCards: computed(() => categoryIds.value.length),
});
function onSelected(categoryId: number, isExpanded: boolean) {
activeCategoryId.value = isExpanded ? categoryId : undefined;
}
@@ -101,6 +109,7 @@ export default defineComponent({
width,
categoryIds,
activeCategoryId,
cardLayout,
onSelected,
};
},

View File

@@ -29,26 +29,12 @@
/>
</div>
<CardExpandTransition>
<div v-show="isExpanded">
<CardExpansionArrow />
<div
class="card__expander"
@click.stop
>
<div class="card__expander__close-button">
<FlatButton
icon="xmark"
@click="collapse()"
/>
</div>
<div class="card__expander__content">
<ScriptsTree
:category-id="categoryId"
:has-top-padding="false"
/>
</div>
</div>
</div>
<CardExpansionPanel
v-show="isExpanded"
:category-id="categoryId"
@on-collapse="collapse"
@click.stop
/>
</CardExpandTransition>
</div>
</template>
@@ -56,30 +42,32 @@
<script lang="ts">
import {
defineComponent, computed, shallowRef,
type PropType,
} from 'vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
import { injectKey } from '@/presentation/injectionSymbols';
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
import CardSelectionIndicator from './CardSelectionIndicator.vue';
import CardExpandTransition from './CardExpandTransition.vue';
import CardExpansionArrow from './CardExpansionArrow.vue';
import CardExpansionPanel from './CardExpansionPanel.vue';
import type { CardLayout } from './UseCardLayout';
export default defineComponent({
components: {
ScriptsTree,
AppIcon,
CardSelectionIndicator,
FlatButton,
CardExpansionPanel,
CardExpandTransition,
CardExpansionArrow,
},
props: {
categoryId: {
type: Number,
required: true,
},
cardLayout: {
type: Object as PropType<CardLayout>,
required: true,
},
activeCategoryId: {
type: Number,
default: undefined,
@@ -129,6 +117,7 @@ export default defineComponent({
cardTitle,
isExpanded,
cardElement,
totalColumns: props.cardLayout.totalColumns,
collapse,
};
},
@@ -141,7 +130,6 @@ export default defineComponent({
@use "./card-gap" as *;
$card-inner-padding : $spacing-absolute-xx-large;
$expanded-margin-top : $spacing-absolute-xx-large;
$card-horizontal-gap : $card-gap;
.card {
@@ -190,44 +178,13 @@ $card-horizontal-gap : $card-gap;
font-size: $font-size-absolute-normal;
}
}
.card__expander {
position: relative;
background-color: $color-primary-darker;
color: $color-on-primary;
display: flex;
align-items: center;
flex-direction: column;
.card__expander__content {
display: flex;
justify-content: center;
word-break: break-word;
max-width: 100%; // Prevents horizontal expansion of inner content (e.g., when a code block is shown)
width: 100%; // Expands the container to fill available horizontal space, enabling alignment of child items.
}
.card__expander__close-button {
font-size: $font-size-absolute-large;
align-self: flex-end;
margin-right: $spacing-absolute-small;
@include clickable;
color: $color-primary-light;
@include hover-or-touch {
color: $color-primary;
}
}
}
&.is-expanded {
.card__inner {
height: auto;
background-color: $color-secondary;
color: $color-on-secondary;
}
.card__expander {
margin-top: $expanded-margin-top;
margin-bottom: $spacing-absolute-xx-large;
}
@include hover-or-touch {
@@ -253,36 +210,32 @@ $card-horizontal-gap : $card-gap;
}
}
}
@mixin adaptive-card($cards-in-row) {
&.card {
$total-times-gap-is-used-in-row: $cards-in-row - 1;
$total-gap-width-in-row: $total-times-gap-is-used-in-row * $card-horizontal-gap;
$available-row-width-for-cards: calc(100% - #{$total-gap-width-in-row});
$available-width-per-card: calc(#{$available-row-width-for-cards} / #{$cards-in-row});
width:$available-width-per-card;
.card__expander {
$all-cards-width: 100% * $cards-in-row;
$additional-padding-width: $card-horizontal-gap * ($cards-in-row - 1);
width: calc(#{$all-cards-width} + #{$additional-padding-width});
}
@for $nth-card from 2 through $cards-in-row { // From second card to rest
&:nth-of-type(#{$cards-in-row}n+#{$nth-card}) {
.card__expander {
$card-left: -100% * ($nth-card - 1);
$additional-space: $card-horizontal-gap * ($nth-card - 1);
margin-left: calc(#{$card-left} - #{$additional-space});
}
}
}
// Ensure new line after last row
$card-after-last: $cards-in-row + 1;
&:nth-of-type(#{$cards-in-row}n+#{$card-after-last}) {
clear: left;
}
.card {
$total-columns: v-bind(totalColumns);
$total-times-gap-is-used-in-row: calc($total-columns - 1);
$total-gap-width-in-row: calc($total-times-gap-is-used-in-row * $card-horizontal-gap);
$available-row-width-for-cards: calc(100% - #{$total-gap-width-in-row});
$available-width-per-card: calc(#{$available-row-width-for-cards} / $total-columns);
width:$available-width-per-card;
:deep(.card__expander) {
$all-cards-width: calc(100% * $total-columns);
$additional-padding-width: calc($card-horizontal-gap * ($total-columns - 1));
width: calc(#{$all-cards-width} + #{$additional-padding-width});
}
// @for $nth-card from 2 through $total-columns { // From second card to rest
// &:nth-of-type(#{$total-columns}n+#{$nth-card}) {
// :deep(.card__expander) {
// $card-left: -100% * ($nth-card - 1);
// $additional-space: $card-horizontal-gap * ($nth-card - 1);
// margin-left: calc(#{$card-left} - #{$additional-space});
// }
// }
// }
// Ensure new line after last row
$card-after-last: $total-columns + 1;
&:nth-of-type(#{$total-columns}n+#{$card-after-last}) {
clear: left;
}
}
.big-screen { @include adaptive-card(3); }
.medium-screen { @include adaptive-card(2); }
.small-screen { @include adaptive-card(1); }
</style>

View File

@@ -0,0 +1,68 @@
import { computed, type Ref } from 'vue';
export function useCardLayout(options: {
readonly containerWidth: Readonly<Ref<number>>;
readonly totalCards: Readonly<Ref<number>>;
}): Readonly<Ref<CardLayout>> {
return computed(() => {
return determineCardLayout(
options.containerWidth.value,
options.totalCards.value,
);
});
}
export interface CardLayout {
readonly totalRows: number;
readonly totalColumns: number;
readonly availableCardWidth: number;
}
function determineCardLayout(
containerWidth: number,
totalCards: number,
): CardLayout {
const containerSize = getContainerSize(containerWidth);
const totalColumns = countTotalColumns(containerSize);
const totalRows = countTotalRows(totalColumns, totalCards);
return {
totalColumns,
totalRows,
availableCardWidth: containerWidth / totalRows,
};
}
enum ContainerSize {
Small,
Medium,
Big,
}
function countTotalRows(totalColumns: number, totalCards: number): number {
return Math.ceil(totalCards / totalColumns);
}
function countTotalColumns(size: ContainerSize): number {
switch (size) {
case ContainerSize.Small:
return 1;
case ContainerSize.Medium:
return 2;
case ContainerSize.Big:
return 3;
default:
throw new Error(`Unknown size: ${size}`);
}
}
function getContainerSize(containerWidth: number): ContainerSize {
const smallBreakpoint = 500;
const bigBreakpoint = 750;
if (containerWidth <= smallBreakpoint) {
return ContainerSize.Small;
}
if (containerWidth < bigBreakpoint) {
return ContainerSize.Medium;
}
return ContainerSize.Big;
}

View File

@@ -68,9 +68,7 @@ export default defineComponent({
flex-direction: column;
flex: 1; // Expands the container to fill available horizontal space, enabling alignment of child items.
max-width: 100%; // Prevents horizontal expansion of inner content (e.g., when a code block is shown)
*:not(:first-child) {
margin-left: $spacing-absolute-small;
}
.header {
display: flex;
flex-direction: row;
@@ -80,6 +78,7 @@ export default defineComponent({
}
.docs {
background: $color-primary-darkest;
margin-left: $spacing-absolute-small;
margin-top: $spacing-relative-x-small;
color: $color-on-primary;
text-transform: none;

View File

@@ -0,0 +1,41 @@
import { onBeforeUnmount } from 'vue';
export function useAnimationFrameLimiter(
cancelAnimationFrame: CancelAnimationFrameFunction = window.cancelAnimationFrame,
requestAnimationFrame: RequestAnimationFrameFunction = window.requestAnimationFrame,
onTeardown: RegisterTeardownCallbackFunction = onBeforeUnmount,
): AnimationFrameLimiter {
let requestId: AnimationFrameId | null = null;
const cancelNextFrame = () => {
if (requestId === null) {
return;
}
cancelAnimationFrame(requestId);
};
const resetNextFrame = (callback: AnimationFrameRequestCallback) => {
cancelNextFrame();
requestId = requestAnimationFrame(callback);
};
onTeardown(() => {
cancelNextFrame();
});
return {
cancelNextFrame,
resetNextFrame,
};
}
export type CancelAnimationFrameFunction = typeof window.cancelAnimationFrame;
export type RequestAnimationFrameFunction = (callback: AnimationFrameRequestCallback) => number;
export type RegisterTeardownCallbackFunction = (callback: () => void) => void;
export type AnimationFrameId = ReturnType<typeof requestAnimationFrame>;
export type AnimationFrameRequestCallback = () => void;
export interface AnimationFrameLimiter {
cancelNextFrame(): void;
resetNextFrame(callback: AnimationFrameRequestCallback): void;
}

View File

@@ -0,0 +1,67 @@
import {
onBeforeMount, onBeforeUnmount,
watch, type Ref,
} from 'vue';
import { throttle, type ThrottleFunction } from '@/application/Common/Timing/Throttle';
import { useResizeObserverPolyfill } from './UseResizeObserverPolyfill';
import { useAnimationFrameLimiter } from './UseAnimationFrameLimiter';
export function useResizeObserver(
config: ResizeObserverConfig,
usePolyfill = useResizeObserverPolyfill,
useFrameLimiter = useAnimationFrameLimiter,
throttler: ThrottleFunction = throttle,
onSetup: LifecycleHookRegistration = onBeforeMount,
onTeardown: LifecycleHookRegistration = onBeforeUnmount,
) {
const { resetNextFrame, cancelNextFrame } = useFrameLimiter();
// This prevents the 'ResizeObserver loop completed with undelivered notifications' error when
// the browser can't process all observations within one animation frame.
// Reference: https://github.com/WICG/resize-observer/issues/38
const { resizeObserverReady } = usePolyfill();
// This ensures compatibility with ancient browsers. All modern browsers support ResizeObserver.
// Compatibility info: https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#browser_compatibility
const throttledCallback = throttler(config.observeCallback, config.throttleInMs);
// Throttling enhances performance during rapid changes such as window resizing.
let observer: ResizeObserver | null;
const disposeObserver = () => {
cancelNextFrame();
observer?.disconnect();
observer = null;
};
onSetup(() => {
watch(() => config.observedElementRef.value, (element) => {
if (!element) {
disposeObserver();
return;
}
resizeObserverReady.then((createObserver) => {
disposeObserver();
observer = createObserver((...args) => {
resetNextFrame(() => throttledCallback(...args));
});
observer.observe(element, config?.observeOptions);
});
}, { immediate: true });
});
onTeardown(() => {
disposeObserver();
});
}
export interface ResizeObserverConfig {
readonly observedElementRef: ObservedElementReference;
readonly throttleInMs: number;
readonly observeCallback: ResizeObserverCallback;
readonly observeOptions?: ResizeObserverOptions;
}
export type ObservedElementReference = Readonly<Ref<HTMLElement | undefined>>;
export type LifecycleHookRegistration = (callback: () => void) => void;

View File

@@ -16,11 +16,17 @@ async function polyfillResizeObserver(): Promise<typeof ResizeObserver> {
return polyfillLoader.getValue();
}
interface ResizeObserverCreator {
(
...args: ConstructorParameters<typeof ResizeObserver>
): ResizeObserver;
}
export function useResizeObserverPolyfill() {
const resizeObserverReady = new Promise<void>((resolve) => {
const resizeObserverReady = new Promise<ResizeObserverCreator>((resolve) => {
onMounted(async () => {
await polyfillResizeObserver();
resolve();
resolve((args) => new ResizeObserver(args));
});
});
return { resizeObserverReady };

View File

@@ -6,10 +6,9 @@
<script lang="ts">
import {
defineComponent, shallowRef, onMounted, onBeforeUnmount, watch,
defineComponent, shallowRef, onMounted, watch,
} from 'vue';
import { useResizeObserverPolyfill } from '@/presentation/components/Shared/Hooks/UseResizeObserverPolyfill';
import { throttle } from '@/application/Common/Timing/Throttle';
import { useResizeObserver } from './Hooks/Resize/UseResizeObserver';
export default defineComponent({
emits: {
@@ -20,31 +19,21 @@ export default defineComponent({
/* eslint-enable @typescript-eslint/no-unused-vars */
},
setup(_, { emit }) {
const { resizeObserverReady } = useResizeObserverPolyfill();
const containerElement = shallowRef<HTMLElement>();
let width = 0;
let height = 0;
let observer: ResizeObserver | undefined;
onMounted(() => {
watch(() => containerElement.value, async (element) => {
if (!element) {
disposeObserver();
return;
}
resizeObserverReady.then(() => {
disposeObserver();
observer = new ResizeObserver(throttle(updateSize, 200));
observer.observe(element);
});
updateSize(); // Do not throttle, immediately inform new width
}, { immediate: true });
useResizeObserver({
observedElementRef: containerElement,
observeCallback: updateSize,
throttleInMs: 200,
});
onBeforeUnmount(() => {
disposeObserver();
onMounted(() => {
watch(() => containerElement.value, async () => {
updateSize();
}, { immediate: true });
});
function updateSize() {
@@ -81,11 +70,6 @@ export default defineComponent({
return { isChanged: true };
}
function disposeObserver() {
observer?.disconnect();
observer = undefined;
}
return {
containerElement,
};

View File

@@ -34,7 +34,7 @@ import {
useFloating, arrow, shift, flip, type Placement, offset, type Side, type Coords, autoUpdate,
} from '@floating-ui/vue';
import { defineComponent, shallowRef, computed } from 'vue';
import { useResizeObserverPolyfill } from '@/presentation/components/Shared/Hooks/UseResizeObserverPolyfill';
import { useResizeObserverPolyfill } from '@/presentation/components/Shared/Hooks/Resize/UseResizeObserverPolyfill';
import { throttle } from '@/application/Common/Timing/Throttle';
import { type TargetEventListener } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEventListener';
import { injectKey } from '@/presentation/injectionSymbols';

View File

@@ -105,5 +105,5 @@ console.log(`Status code: ${status.code}`);
- This is useful for websites that do not respond to HEAD requests, such as those behind certain CDN or web application firewalls.
- Provide patterns as regular expressions (`RegExp`), allowing them to match any part of a URL.
- Examples:
- To match any URL starting with "https://example.com/api": `/^https:\/\/example\.com\/api/`
- To match any domain ending with "cloudflare.com": `/^https:\/\/.*\.cloudflare\.com\//`
- To match any URL starting with `https://example.com/api`: `/^https:\/\/example\.com\/api/`
- To match any domain ending with `cloudflare.com`: `/^https:\/\/.*\.cloudflare\.com\//`

View File

@@ -10,6 +10,7 @@ describe('revert toggle', () => {
cardIndex: 1, // first is often cleanup that may lack revert button
});
cy.get('.toggle-switch')
.filter(':visible') // Avoid side-effects from hidden cards
.first()
.as('toggleSwitch');
});

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { TimerStub } from '@tests/unit/shared/Stubs/TimerStub';
import { throttle, type ThrottleOptions } from '@/application/Common/Timing/Throttle';
import { throttle, type ThrottleFunction, type ThrottleOptions } from '@/application/Common/Timing/Throttle';
import type { Timer } from '@/application/Common/Timing/Timer';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
@@ -275,7 +275,7 @@ describe('throttle', () => {
});
});
type CallbackType = Parameters<typeof throttle>[0];
type CallbackType = Parameters<ThrottleFunction>[0];
class TestContext {
private options: Partial<ThrottleOptions> | undefined = {
@@ -315,7 +315,7 @@ class TestContext {
return this;
}
public throttle(): ReturnType<typeof throttle> {
public throttle(): ReturnType<ThrottleFunction> {
return throttle(
this.callback,
this.waitInMs,

View File

@@ -1,258 +1,395 @@
import { describe, it, expect } from 'vitest';
import type { CategoryData, CategoryOrScriptData } from '@/application/collections/';
import { type CategoryFactoryType, parseCategory } from '@/application/Parser/CategoryParser';
import { parseScript } from '@/application/Parser/Script/ScriptParser';
import { parseDocs } from '@/application/Parser/DocumentationParser';
import { ScriptCompilerStub } from '@tests/unit/shared/Stubs/ScriptCompilerStub';
import { type CategoryFactory, parseCategory } from '@/application/Parser/CategoryParser';
import { type ScriptParser } from '@/application/Parser/Script/ScriptParser';
import { type DocsParser } from '@/application/Parser/DocumentationParser';
import { CategoryCollectionParseContextStub } from '@tests/unit/shared/Stubs/CategoryCollectionParseContextStub';
import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub';
import { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub';
import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { NodeType } from '@/application/Parser/NodeValidation/NodeType';
import { expectThrowsNodeError, type ITestScenario, NodeValidationTestRunner } from '@tests/unit/application/Parser/NodeValidation/NodeValidatorTestRunner';
import { getAbsentCollectionTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
import { NodeDataType } from '@/application/Parser/NodeValidation/NodeDataType';
import type { ICategoryCollectionParseContext } from '@/application/Parser/Script/ICategoryCollectionParseContext';
import { Category } from '@/domain/Category';
import { createScriptDataWithCall, createScriptDataWithCode, createScriptDataWithoutCallOrCodes } from '@tests/unit/shared/Stubs/ScriptDataStub';
import { createScriptDataWithCall, createScriptDataWithCode } from '@tests/unit/shared/Stubs/ScriptDataStub';
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { ErrorWrapperStub } from '@tests/unit/shared/Stubs/ErrorWrapperStub';
import type { NodeDataValidatorFactory } from '@/application/Parser/NodeValidation/NodeDataValidator';
import { NodeDataValidatorStub, createNodeDataValidatorFactoryStub } from '@tests/unit/shared/Stubs/NodeDataValidatorStub';
import type { CategoryNodeErrorContext, UnknownNodeErrorContext } from '@/application/Parser/NodeValidation/NodeDataErrorContext';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { createCategoryFactorySpy } from '@tests/unit/shared/Stubs/CategoryFactoryStub';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { ScriptParserStub } from '@tests/unit/shared/Stubs/ScriptParserStub';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { indentText } from '@tests/shared/Text';
import { itThrowsContextualError } from './ContextualErrorTester';
import { itValidatesName, itValidatesDefinedData, itAsserts } from './NodeDataValidationTester';
import { generateDataValidationTestScenarios } from './DataValidationTestScenarioGenerator';
describe('CategoryParser', () => {
describe('parseCategory', () => {
describe('invalid category data', () => {
describe('validates script data', () => {
describe('satisfies shared node tests', () => {
new NodeValidationTestRunner()
.testInvalidNodeName((invalidName) => {
return createTest(
new CategoryDataStub().withName(invalidName),
);
})
.testMissingNodeData((node) => {
return createTest(node as CategoryData);
});
});
describe('throws when category children is absent', () => {
itEachAbsentCollectionValue<CategoryOrScriptData>((absentValue) => {
// arrange
const categoryName = 'test';
const expectedMessage = `"${categoryName}" has no children.`;
const category = new CategoryDataStub()
.withName(categoryName)
.withChildren(absentValue);
// act
const test = createTest(category);
// assert
expectThrowsNodeError(test, expectedMessage);
}, { excludeUndefined: true, excludeNull: true });
});
describe('throws when category child is missing', () => {
new NodeValidationTestRunner()
.testMissingNodeData((missingNode) => {
// arrange
const invalidChildNode = missingNode;
const parent = new CategoryDataStub()
.withName('parent')
.withChildren([new CategoryDataStub().withName('valid child'), invalidChildNode]);
return ({
// act
act: () => new TestBuilder()
.withData(parent)
.parseCategory(),
// assert
expectedContext: {
selfNode: invalidChildNode,
parentNode: parent,
},
});
});
});
it('throws when node is neither a category or a script', () => {
// arrange
const expectedError = 'Node is neither a category or a script.';
const invalidChildNode = { property: 'non-empty-value' } as never as CategoryOrScriptData;
const parent = new CategoryDataStub()
.withName('parent')
.withChildren([new CategoryDataStub().withName('valid child'), invalidChildNode]);
describe('validation', () => {
describe('validates for name', () => {
// arrange
const expectedName = 'expected category name to be validated';
const category = new CategoryDataStub()
.withName(expectedName);
const expectedContext: CategoryNodeErrorContext = {
type: NodeDataType.Category,
selfNode: category,
};
itValidatesName((validatorFactory) => {
// act
const test: ITestScenario = {
// act
act: () => new TestBuilder()
.withData(parent)
.parseCategory(),
// assert
expectedContext: {
selfNode: invalidChildNode,
parentNode: parent,
},
};
new TestBuilder()
.withData(category)
.withValidatorFactory(validatorFactory)
.parseCategory();
// assert
expectThrowsNodeError(test, expectedError);
return {
expectedNameToValidate: expectedName,
expectedErrorContext: expectedContext,
};
});
describe('throws when category child is invalid category', () => {
new NodeValidationTestRunner().testInvalidNodeName((invalidName) => {
// arrange
const invalidChildNode = new CategoryDataStub()
.withName(invalidName);
const parent = new CategoryDataStub()
.withName('parent')
.withChildren([new CategoryDataStub().withName('valid child'), invalidChildNode]);
return ({
// act
act: () => new TestBuilder()
.withData(parent)
.parseCategory(),
// assert
expectedContext: {
type: NodeType.Category,
selfNode: invalidChildNode,
parentNode: parent,
});
describe('validates for defined data', () => {
// arrange
const category = new CategoryDataStub();
const expectedContext: CategoryNodeErrorContext = {
type: NodeDataType.Category,
selfNode: category,
};
itValidatesDefinedData(
(validatorFactory) => {
// act
new TestBuilder()
.withData(category)
.withValidatorFactory(validatorFactory)
.parseCategory();
// assert
return {
expectedDataToValidate: category,
expectedErrorContext: expectedContext,
};
},
);
});
describe('validates that category has some children', () => {
const categoryName = 'test';
const testScenarios = generateDataValidationTestScenarios<CategoryData>({
expectFail: getAbsentCollectionTestCases<CategoryOrScriptData>().map(({
valueName, absentValue: absentCollectionValue,
}) => ({
description: `with \`${valueName}\` value as children`,
data: new CategoryDataStub()
.withName(categoryName)
.withChildren(absentCollectionValue as unknown as CategoryOrScriptData[]),
})),
expectPass: [{
description: 'has single children',
data: new CategoryDataStub()
.withName(categoryName)
.withChildren([createScriptDataWithCode()]),
}],
});
testScenarios.forEach(({
description, expectedPass, data: categoryData,
}) => {
describe(description, () => {
itAsserts({
expectedConditionResult: expectedPass,
test: (validatorFactory) => {
const expectedMessage = `"${categoryName}" has no children.`;
const expectedContext: CategoryNodeErrorContext = {
type: NodeDataType.Category,
selfNode: categoryData,
};
// act
try {
new TestBuilder()
.withData(categoryData)
.withValidatorFactory(validatorFactory)
.parseCategory();
} catch { /* It may throw due to assertions not being evaluated */ }
// assert
return {
expectedErrorMessage: expectedMessage,
expectedErrorContext: expectedContext,
};
},
});
});
});
function createTest(category: CategoryData): ITestScenario {
return {
act: () => new TestBuilder()
.withData(category)
.parseCategory(),
expectedContext: {
type: NodeType.Category,
selfNode: category,
},
};
}
});
it(`rethrows exception if ${Category.name} cannot be constructed`, () => {
describe('validates that a child is a category or a script', () => {
// arrange
const expectedError = 'category creation failed';
const factoryMock: CategoryFactoryType = () => { throw new Error(expectedError); };
const data = new CategoryDataStub();
// act
const act = () => new TestBuilder()
.withData(data)
.withFactory(factoryMock)
.parseCategory();
// expect
expectThrowsNodeError({
act,
expectedContext: {
type: NodeType.Category,
selfNode: data,
},
}, expectedError);
const testScenarios = generateDataValidationTestScenarios<CategoryOrScriptData>({
expectFail: [{
description: 'child has incorrect properties',
data: { property: 'non-empty-value' } as unknown as CategoryOrScriptData,
}],
expectPass: [
{
description: 'child is a category',
data: new CategoryDataStub(),
},
{
description: 'child is a script with call',
data: createScriptDataWithCall(),
},
{
description: 'child is a script with code',
data: createScriptDataWithCode(),
},
],
});
testScenarios.forEach(({
description, expectedPass, data: childData,
}) => {
describe(description, () => {
itAsserts({
expectedConditionResult: expectedPass,
test: (validatorFactory) => {
const expectedError = 'Node is neither a category or a script.';
const parent = new CategoryDataStub()
.withName('parent')
.withChildren([new CategoryDataStub().withName('valid child'), childData]);
const expectedContext: UnknownNodeErrorContext = {
selfNode: childData,
parentNode: parent,
};
// act
new TestBuilder()
.withData(parent)
.withValidatorFactory(validatorFactory)
.parseCategory();
// assert
return {
expectedErrorMessage: expectedError,
expectedErrorContext: expectedContext,
};
},
});
});
});
});
describe('validates children recursively', () => {
describe('validates (1th-level) child data', () => {
// arrange
const expectedName = 'child category';
const child = new CategoryDataStub()
.withName(expectedName);
const parent = new CategoryDataStub()
.withName('parent')
.withChildren([child]);
const expectedContext: UnknownNodeErrorContext = {
selfNode: child,
parentNode: parent,
};
itValidatesDefinedData(
(validatorFactory) => {
// act
new TestBuilder()
.withData(parent)
.withValidatorFactory(validatorFactory)
.parseCategory();
// assert
return {
expectedDataToValidate: child,
expectedErrorContext: expectedContext,
};
},
);
});
describe('validates that (2nd-level) child name', () => {
// arrange
const expectedName = 'grandchild category';
const grandChild = new CategoryDataStub()
.withName(expectedName);
const child = new CategoryDataStub()
.withChildren([grandChild])
.withName('child category');
const parent = new CategoryDataStub()
.withName('parent')
.withChildren([child]);
const expectedContext: CategoryNodeErrorContext = {
type: NodeDataType.Category,
selfNode: grandChild,
parentNode: child,
};
itValidatesName((validatorFactory) => {
// act
new TestBuilder()
.withData(parent)
.withValidatorFactory(validatorFactory)
.parseCategory();
// assert
return {
expectedNameToValidate: expectedName,
expectedErrorContext: expectedContext,
};
});
});
});
});
it('returns expected docs', () => {
describe('rethrows exception if category factory fails', () => {
// arrange
const givenData = new CategoryDataStub();
const expectedContextMessage = 'Failed to parse category.';
const expectedError = new Error();
// act & assert
itThrowsContextualError({
throwingAction: (wrapError) => {
const validatorStub = new NodeDataValidatorStub();
validatorStub.createContextualErrorMessage = (message) => message;
const factoryMock: CategoryFactory = () => {
throw expectedError;
};
new TestBuilder()
.withCategoryFactory(factoryMock)
.withValidatorFactory(() => validatorStub)
.withErrorWrapper(wrapError)
.withData(givenData)
.parseCategory();
},
expectedWrappedError: expectedError,
expectedContextMessage,
});
});
it('parses docs correctly', () => {
// arrange
const url = 'https://privacy.sexy';
const expected = parseDocs({ docs: url });
const category = new CategoryDataStub()
const categoryData = new CategoryDataStub()
.withDocs(url);
const parseDocs: DocsParser = (data) => {
return [
`parsed docs: ${JSON.stringify(data)}`,
];
};
const expectedDocs = parseDocs(categoryData);
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
// act
const actual = new TestBuilder()
.withData(category)
.parseCategory()
.docs;
const actualCategory = new TestBuilder()
.withData(categoryData)
.withCategoryFactory(categoryFactorySpy)
.withDocsParser(parseDocs)
.parseCategory();
// assert
expect(actual).to.deep.equal(expected);
const actualDocs = getInitParameters(actualCategory)?.docs;
expect(actualDocs).to.deep.equal(expectedDocs);
});
describe('parses expected subscript', () => {
it('single script with code', () => {
it('parses single script correctly', () => {
// arrange
const script = createScriptDataWithCode();
const context = new CategoryCollectionParseContextStub();
const expected = [parseScript(script, context)];
const category = new CategoryDataStub()
.withChildren([script]);
const expectedScript = new ScriptStub('expected script');
const scriptParser = new ScriptParserStub();
const childScriptData = createScriptDataWithCode();
const categoryData = new CategoryDataStub()
.withChildren([childScriptData]);
scriptParser.setupParsedResultForData(childScriptData, expectedScript);
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
// act
const actual = new TestBuilder()
.withData(category)
.withContext(context)
.parseCategory()
.scripts;
const actualCategory = new TestBuilder()
.withData(categoryData)
.withScriptParser(scriptParser.get())
.withCategoryFactory(categoryFactorySpy)
.parseCategory();
// assert
expect(actual).to.deep.equal(expected);
const actualScripts = getInitParameters(actualCategory)?.scripts;
expectExists(actualScripts);
expect(actualScripts).to.have.lengthOf(1);
const actualScript = actualScripts[0];
expect(actualScript).to.equal(expectedScript);
});
it('single script with function call', () => {
it('parses multiple scripts correctly', () => {
// arrange
const script = createScriptDataWithCall();
const compiler = new ScriptCompilerStub()
.withCompileAbility(script);
const context = new CategoryCollectionParseContextStub()
.withCompiler(compiler);
const expected = [parseScript(script, context)];
const category = new CategoryDataStub()
.withChildren([script]);
const expectedScripts = [
new ScriptStub('expected-first-script'),
new ScriptStub('expected-second-script'),
];
const childrenData = [
createScriptDataWithCall(),
createScriptDataWithCode(),
];
const scriptParser = new ScriptParserStub();
childrenData.forEach((_, index) => {
scriptParser.setupParsedResultForData(childrenData[index], expectedScripts[index]);
});
const categoryData = new CategoryDataStub()
.withChildren(childrenData);
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
// act
const actual = new TestBuilder()
.withData(category)
.withContext(context)
.parseCategory()
.scripts;
const actualCategory = new TestBuilder()
.withScriptParser(scriptParser.get())
.withData(categoryData)
.withCategoryFactory(categoryFactorySpy)
.parseCategory();
// assert
expect(actual).to.deep.equal(expected);
const actualParsedScripts = getInitParameters(actualCategory)?.scripts;
expectExists(actualParsedScripts);
expect(actualParsedScripts.length).to.equal(expectedScripts.length);
expect(actualParsedScripts).to.have.members(expectedScripts);
});
it('multiple scripts with function call and code', () => {
it('parses all scripts with correct context', () => {
// arrange
const callableScript = createScriptDataWithCall();
const scripts = [callableScript, createScriptDataWithCode()];
const category = new CategoryDataStub()
.withChildren(scripts);
const compiler = new ScriptCompilerStub()
.withCompileAbility(callableScript);
const context = new CategoryCollectionParseContextStub()
.withCompiler(compiler);
const expected = scripts.map((script) => parseScript(script, context));
const expectedParseContext = new CategoryCollectionParseContextStub();
const scriptParser = new ScriptParserStub();
const childrenData = [
createScriptDataWithCode(),
createScriptDataWithCode(),
createScriptDataWithCode(),
];
const categoryData = new CategoryDataStub()
.withChildren(childrenData);
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
// act
const actual = new TestBuilder()
.withData(category)
.withContext(context)
.parseCategory()
.scripts;
const actualCategory = new TestBuilder()
.withData(categoryData)
.withContext(expectedParseContext)
.withScriptParser(scriptParser.get())
.withCategoryFactory(categoryFactorySpy)
.parseCategory();
// assert
expect(actual).to.deep.equal(expected);
});
it('script is created with right context', () => { // test through script validation logic
// arrange
const commentDelimiter = 'should not throw';
const duplicatedCode = `${commentDelimiter} duplicate-line\n${commentDelimiter} duplicate-line`;
const parseContext = new CategoryCollectionParseContextStub()
.withSyntax(new LanguageSyntaxStub().withCommentDelimiters(commentDelimiter));
const category = new CategoryDataStub()
.withChildren([
new CategoryDataStub()
.withName('sub-category')
.withChildren([
createScriptDataWithoutCallOrCodes()
.withCode(duplicatedCode),
]),
]);
// act
const act = () => new TestBuilder()
.withData(category)
.withContext(parseContext)
.parseCategory()
.scripts;
// assert
expect(act).to.not.throw();
const actualParsedScripts = getInitParameters(actualCategory)?.scripts;
expectExists(actualParsedScripts);
const actualParseContexts = actualParsedScripts.map(
(s) => scriptParser.getParseParameters(s)[1],
);
expect(
actualParseContexts.every(
(actualParseContext) => actualParseContext === expectedParseContext,
),
formatAssertionMessage([
`Expected all elements to be ${JSON.stringify(expectedParseContext)}`,
'All elements:',
indentText(JSON.stringify(actualParseContexts)),
]),
).to.equal(true);
});
});
it('returns expected subcategories', () => {
// arrange
const expected = [new CategoryDataStub()
.withName('test category')
.withChildren([createScriptDataWithCode()]),
];
const category = new CategoryDataStub()
const expectedChildCategory = new CategoryStub(33);
const childCategoryData = new CategoryDataStub()
.withName('expected child category')
.withChildren([createScriptDataWithCode()]);
const categoryData = new CategoryDataStub()
.withName('category name')
.withChildren(expected);
.withChildren([childCategoryData]);
const { categoryFactorySpy, getInitParameters } = createCategoryFactorySpy();
// act
const actual = new TestBuilder()
.withData(category)
.parseCategory()
.subCategories;
const actualCategory = new TestBuilder()
.withData(categoryData)
.withCategoryFactory((parameters) => {
if (parameters.name === childCategoryData.category) {
return expectedChildCategory;
}
return categoryFactorySpy(parameters);
})
.parseCategory();
// assert
expect(actual).to.have.lengthOf(1);
expect(actual[0].name).to.equal(expected[0].category);
expect(actual[0].scripts.length).to.equal(expected[0].children.length);
const actualSubcategories = getInitParameters(actualCategory)?.subcategories;
expectExists(actualSubcategories);
expect(actualSubcategories).to.have.lengthOf(1);
expect(actualSubcategories[0]).to.equal(expectedChildCategory);
});
});
});
@@ -262,24 +399,62 @@ class TestBuilder {
private context: ICategoryCollectionParseContext = new CategoryCollectionParseContextStub();
private factory?: CategoryFactoryType = undefined;
private categoryFactory: CategoryFactory = () => new CategoryStub(33);
private errorWrapper: ErrorWithContextWrapper = new ErrorWrapperStub().get();
private validatorFactory: NodeDataValidatorFactory = createNodeDataValidatorFactoryStub;
private docsParser: DocsParser = () => ['docs'];
private scriptParser: ScriptParser = new ScriptParserStub().get();
public withData(data: CategoryData) {
this.data = data;
return this;
}
public withContext(context: ICategoryCollectionParseContext) {
public withContext(context: ICategoryCollectionParseContext): this {
this.context = context;
return this;
}
public withFactory(factory: CategoryFactoryType) {
this.factory = factory;
public withCategoryFactory(categoryFactory: CategoryFactory): this {
this.categoryFactory = categoryFactory;
return this;
}
public withValidatorFactory(validatorFactory: NodeDataValidatorFactory): this {
this.validatorFactory = validatorFactory;
return this;
}
public withErrorWrapper(errorWrapper: ErrorWithContextWrapper): this {
this.errorWrapper = errorWrapper;
return this;
}
public withScriptParser(scriptParser: ScriptParser): this {
this.scriptParser = scriptParser;
return this;
}
public withDocsParser(docsParser: DocsParser): this {
this.docsParser = docsParser;
return this;
}
public parseCategory() {
return parseCategory(this.data, this.context, this.factory);
return parseCategory(
this.data,
this.context,
{
createCategory: this.categoryFactory,
wrapError: this.errorWrapper,
createValidator: this.validatorFactory,
parseScript: this.scriptParser,
parseDocs: this.docsParser,
},
);
}
}

View File

@@ -0,0 +1,121 @@
import { describe, it, expect } from 'vitest';
import { CustomError } from '@/application/Common/CustomError';
import { wrapErrorWithAdditionalContext } from '@/application/Parser/ContextualError';
describe('wrapErrorWithAdditionalContext', () => {
it('preserves the original error when wrapped', () => {
// arrange
const expectedError = new Error();
const context = new TestContext()
.withError(expectedError);
// act
const error = context.wrap();
// assert
const actualError = extractInnerErrorFromContextualError(error);
expect(actualError).to.equal(expectedError);
});
it('maintains the original error when re-wrapped', () => {
// arrange
const expectedError = new Error();
// act
const firstError = new TestContext()
.withError(expectedError)
.withAdditionalContext('first error')
.wrap();
const secondError = new TestContext()
.withError(firstError)
.withAdditionalContext('second error')
.wrap();
// assert
const actualError = extractInnerErrorFromContextualError(secondError);
expect(actualError).to.equal(expectedError);
});
it(`the object extends ${CustomError.name}`, () => {
// arrange
const expected = CustomError;
// act
const error = new TestContext()
.wrap();
// assert
expect(error).to.be.an.instanceof(expected);
});
describe('error message construction', () => {
it('includes the message from the original error', () => {
// arrange
const expectedOriginalErrorMessage = 'Message from the inner error';
// act
const error = new TestContext()
.withError(new Error(expectedOriginalErrorMessage))
.wrap();
// assert
expect(error.message).contains(expectedOriginalErrorMessage);
});
it('appends provided additional context to the error message', () => {
// arrange
const expectedAdditionalContext = 'Expected additional context message';
// act
const error = new TestContext()
.withAdditionalContext(expectedAdditionalContext)
.wrap();
// assert
expect(error.message).contains(expectedAdditionalContext);
});
it('appends multiple contexts to the error message in sequential order', () => {
// arrange
const expectedFirstContext = 'First context';
const expectedSecondContext = 'Second context';
// act
const firstError = new TestContext()
.withAdditionalContext(expectedFirstContext)
.wrap();
const secondError = new TestContext()
.withError(firstError)
.withAdditionalContext(expectedSecondContext)
.wrap();
// assert
const messageLines = secondError.message.split('\n');
expect(messageLines).to.contain(`1: ${expectedFirstContext}`);
expect(messageLines).to.contain(`2: ${expectedSecondContext}`);
});
});
});
class TestContext {
private error: Error = new Error();
private additionalContext = `[${TestContext.name}] additional context`;
public withError(error: Error) {
this.error = error;
return this;
}
public withAdditionalContext(additionalContext: string) {
this.additionalContext = additionalContext;
return this;
}
public wrap(): ReturnType<typeof wrapErrorWithAdditionalContext> {
return wrapErrorWithAdditionalContext(
this.error,
this.additionalContext,
);
}
}
function extractInnerErrorFromContextualError(error: Error): Error {
const innerErrorProperty = 'innerError';
if (!(innerErrorProperty in error)) {
throw new Error(`${innerErrorProperty} property is missing`);
}
const actualError = error[innerErrorProperty];
return actualError as Error;
}

View File

@@ -0,0 +1,53 @@
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { indentText } from '@tests/shared/Text';
import { ErrorWrapperStub } from '@tests/unit/shared/Stubs/ErrorWrapperStub';
interface ContextualErrorTestScenario {
readonly throwingAction: (wrapError: ErrorWithContextWrapper) => void;
readonly expectedWrappedError: Error;
readonly expectedContextMessage: string;
}
export function itThrowsContextualError(
testScenario: ContextualErrorTestScenario,
) {
it('throws wrapped error', () => {
// arrange
const expectedError = new Error();
const wrapperStub = new ErrorWrapperStub()
.withError(expectedError);
// act
const act = () => testScenario.throwingAction(wrapperStub.get());
// assert
expect(act).to.throw(expectedError);
});
it('wraps internal error', () => {
// arrange
const expectedInternalError = testScenario.expectedWrappedError;
const wrapperStub = new ErrorWrapperStub();
// act
try {
testScenario.throwingAction(wrapperStub.get());
} catch { /* Swallow */ }
// assert
expect(wrapperStub.lastError).to.deep.equal(expectedInternalError);
});
it('includes expected context', () => {
// arrange
const { expectedContextMessage: expectedContext } = testScenario;
const wrapperStub = new ErrorWrapperStub();
// act
try {
testScenario.throwingAction(wrapperStub.get());
} catch { /* Swallow */ }
// assert
expectExists(wrapperStub.lastContext);
expect(wrapperStub.lastContext).to.equal(expectedContext, formatAssertionMessage([
'Unexpected additional context (additional message added to the wrapped error).',
`Actual additional context:\n${indentText(wrapperStub.lastContext)}`,
`Expected additional context:\n${indentText(expectedContext)}`,
]));
});
}

View File

@@ -0,0 +1,36 @@
export interface DataValidationTestScenario<T> {
readonly description: string;
readonly data: T;
readonly expectedPass: boolean;
readonly expectedMessage?: string;
}
export function generateDataValidationTestScenarios<T>(
...conditionBasedScenarios: DataValidationConditionBasedTestScenario<T>[]
): DataValidationTestScenario<T>[] {
return conditionBasedScenarios.flatMap((conditionScenario) => [
conditionScenario.expectFail.map((failDefinition): DataValidationTestScenario<T> => ({
description: `fails: "${failDefinition.description}"`,
data: failDefinition.data,
expectedPass: false,
expectedMessage: conditionScenario.assertErrorMessage,
})),
conditionScenario.expectPass.map((passDefinition): DataValidationTestScenario<T> => ({
description: `passes: "${passDefinition.description}"`,
data: passDefinition.data,
expectedPass: true,
expectedMessage: conditionScenario.assertErrorMessage,
})),
].flat());
}
interface DataValidationConditionBasedTestScenario<T> {
readonly assertErrorMessage?: string;
readonly expectPass: readonly DataValidationScenarioDefinition<T>[];
readonly expectFail: readonly DataValidationScenarioDefinition<T>[];
}
interface DataValidationScenarioDefinition<T> {
readonly description: string;
readonly data: T;
}

View File

@@ -0,0 +1,213 @@
import { it } from 'vitest';
import type { NodeDataValidator, NodeDataValidatorFactory } from '@/application/Parser/NodeValidation/NodeDataValidator';
import type { NodeDataErrorContext } from '@/application/Parser/NodeValidation/NodeDataErrorContext';
import { NodeDataValidatorStub } from '@tests/unit/shared/Stubs/NodeDataValidatorStub';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import type { CategoryOrScriptData } from '@/application/collections/';
import type { FunctionKeys } from '@/TypeHelpers';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { indentText } from '@tests/shared/Text';
type NodeValidationTestFunction<TExpectation> = (
factory: NodeDataValidatorFactory,
) => TExpectation;
interface ValidNameExpectation {
readonly expectedNameToValidate: string;
readonly expectedErrorContext: NodeDataErrorContext;
}
export function itValidatesName(
test: NodeValidationTestFunction<ValidNameExpectation>,
) {
it('validates for name', () => {
// arrange
const validator = new NodeDataValidatorStub();
const factoryStub: NodeDataValidatorFactory = () => validator;
// act
test(factoryStub);
// assert
const call = validator.callHistory.find((c) => c.methodName === 'assertValidName');
expectExists(call);
});
it('validates for name with correct name', () => {
// arrange
const validator = new NodeDataValidatorStub();
const factoryStub: NodeDataValidatorFactory = () => validator;
// act
const expectation = test(factoryStub);
// assert
const expectedName = expectation.expectedNameToValidate;
const names = validator.callHistory
.filter((c) => c.methodName === 'assertValidName')
.flatMap((c) => c.args[0]);
expect(names).to.include(expectedName);
});
it('validates for name with correct context', () => {
expectCorrectContextForFunctionCall({
methodName: 'assertValidName',
act: test,
expectContext: (expectation) => expectation.expectedErrorContext,
});
});
}
interface ValidDataExpectation {
readonly expectedDataToValidate: CategoryOrScriptData;
readonly expectedErrorContext: NodeDataErrorContext;
}
export function itValidatesDefinedData(
test: NodeValidationTestFunction<ValidDataExpectation>,
) {
it('validates data', () => {
// arrange
const validator = new NodeDataValidatorStub();
const factoryStub: NodeDataValidatorFactory = () => validator;
// act
test(factoryStub);
// assert
const call = validator.callHistory.find((c) => c.methodName === 'assertDefined');
expectExists(call);
});
it('validates data with correct data', () => {
// arrange
const validator = new NodeDataValidatorStub();
const factoryStub: NodeDataValidatorFactory = () => validator;
// act
const expectation = test(factoryStub);
// assert
const expectedData = expectation.expectedDataToValidate;
const calls = validator.callHistory.filter((c) => c.methodName === 'assertDefined');
const names = calls.flatMap((c) => c.args[0]);
expect(names).to.include(expectedData);
});
it('validates data with correct context', () => {
expectCorrectContextForFunctionCall({
methodName: 'assertDefined',
act: test,
expectContext: (expectation) => expectation.expectedErrorContext,
});
});
}
interface AssertionExpectation {
readonly expectedErrorMessage: string;
readonly expectedErrorContext: NodeDataErrorContext;
}
export function itAsserts(
testScenario: {
readonly test: NodeValidationTestFunction<AssertionExpectation>,
readonly expectedConditionResult: boolean;
},
) {
it('asserts with correct message', () => {
// arrange
const validator = new NodeDataValidatorStub()
.withAssertThrowsOnFalseCondition(false);
const factoryStub: NodeDataValidatorFactory = () => validator;
// act
const expectation = testScenario.test(factoryStub);
// assert
const expectedError = expectation.expectedErrorMessage;
const calls = validator.callHistory.filter((c) => c.methodName === 'assert');
const actualMessages = calls.map((call) => {
const [, message] = call.args;
return message;
});
expect(actualMessages).to.include(expectedError);
});
it('asserts with correct context', () => {
expectCorrectContextForFunctionCall({
methodName: 'assert',
act: testScenario.test,
expectContext: (expectation) => expectation.expectedErrorContext,
});
});
it('asserts with correct condition result', () => {
// arrange
const expectedEvaluationResult = testScenario.expectedConditionResult;
const validator = new NodeDataValidatorStub()
.withAssertThrowsOnFalseCondition(false);
const factoryStub: NodeDataValidatorFactory = () => validator;
// act
const expectation = testScenario.test(factoryStub);
// assert
const assertCalls = validator.callHistory
.filter((call) => call.methodName === 'assert');
expect(assertCalls).to.have.length.greaterThan(0);
const assertCallsWithMessage = assertCalls
.filter((call) => {
const [, message] = call.args;
return message === expectation.expectedErrorMessage;
});
expect(assertCallsWithMessage).to.have.length.greaterThan(0);
const evaluationResults = assertCallsWithMessage
.map((call) => {
const [predicate] = call.args;
return predicate as (() => boolean);
})
.map((predicate) => predicate());
expect(evaluationResults).to.include(expectedEvaluationResult);
});
}
function expectCorrectContextForFunctionCall<T>(testScenario: {
methodName: FunctionKeys<NodeDataValidator>,
act: NodeValidationTestFunction<T>,
expectContext: (actionResult: T) => NodeDataErrorContext,
}) {
// arrange
const { methodName } = testScenario;
const createdValidators = new Array<{
readonly validator: NodeDataValidatorStub;
readonly context: NodeDataErrorContext;
}>();
const factoryStub: NodeDataValidatorFactory = (context) => {
const validator = new NodeDataValidatorStub()
.withAssertThrowsOnFalseCondition(false);
createdValidators.push(({
validator,
context,
}));
return validator;
};
// act
const actionResult = testScenario.act(factoryStub);
// assert
const expectedContext = testScenario.expectContext(actionResult);
const providedContexts = createdValidators
.filter((v) => v.validator.callHistory.find((c) => c.methodName === methodName))
.map((v) => v.context);
expectDeepIncludes( // to.deep.include is not working
providedContexts,
expectedContext,
formatAssertionMessage([
'Error context mismatch.',
'Provided contexts do not include the expected context.',
'Expected context:',
indentText(JSON.stringify(expectedContext, undefined, 2)),
'Provided contexts:',
indentText(JSON.stringify(providedContexts, undefined, 2)),
]),
);
}
function expectDeepIncludes<T>(
array: readonly T[],
item: T,
message: string,
) {
const serializeItem = (c) => JSON.stringify(c);
const serializedContexts = array.map((c) => serializeItem(c));
const serializedExpectedContext = serializeItem(item);
expect(serializedContexts).to.include(serializedExpectedContext, formatAssertionMessage([
'Error context mismatch.',
'Provided contexts do not include the expected context.',
'Expected context:',
indentText(JSON.stringify(message, undefined, 2)),
'Provided contexts:',
indentText(JSON.stringify(message, undefined, 2)),
]));
}

View File

@@ -1,60 +0,0 @@
import { describe, it, expect } from 'vitest';
import { type INodeDataErrorContext, NodeDataError } from '@/application/Parser/NodeValidation/NodeDataError';
import { NodeDataErrorContextStub } from '@tests/unit/shared/Stubs/NodeDataErrorContextStub';
import { NodeType } from '@/application/Parser/NodeValidation/NodeType';
import { CustomError } from '@/application/Common/CustomError';
describe('NodeDataError', () => {
it('sets message as expected', () => {
// arrange
const message = 'message';
const context = new NodeDataErrorContextStub();
const expected = `[${NodeType[context.type]}] ${message}`;
// act
const sut = new NodeDataErrorBuilder()
.withContext(context)
.withMessage(expected)
.build();
// assert
expect(sut.message).to.include(expected);
});
it('sets context as expected', () => {
// arrange
const expected = new NodeDataErrorContextStub();
// act
const sut = new NodeDataErrorBuilder()
.withContext(expected)
.build();
// assert
expect(sut.context).to.equal(expected);
});
it('extends CustomError', () => {
// arrange
const expected = CustomError;
// act
const sut = new NodeDataErrorBuilder()
.build();
// assert
expect(sut).to.be.an.instanceof(expected);
});
});
class NodeDataErrorBuilder {
private message = 'error';
private context: INodeDataErrorContext = new NodeDataErrorContextStub();
public withContext(context: INodeDataErrorContext) {
this.context = context;
return this;
}
public withMessage(message: string) {
this.message = message;
return this;
}
public build(): NodeDataError {
return new NodeDataError(this.message, this.context);
}
}

View File

@@ -0,0 +1,242 @@
import { describe, it, expect } from 'vitest';
import { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub';
import type { NodeData } from '@/application/Parser/NodeValidation/NodeData';
import { createNodeDataErrorContextStub } from '@tests/unit/shared/Stubs/NodeDataErrorContextStub';
import type { NodeDataErrorContext } from '@/application/Parser/NodeValidation/NodeDataErrorContext';
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
import { ContextualNodeDataValidator, createNodeDataValidator, type NodeDataValidator } from '@/application/Parser/NodeValidation/NodeDataValidator';
import type { NodeContextErrorMessageCreator } from '@/application/Parser/NodeValidation/NodeDataErrorContextMessage';
import { getAbsentObjectTestCases, getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
describe('createNodeDataValidator', () => {
it(`returns an instance of ${ContextualNodeDataValidator.name}`, () => {
// arrange
const context = createNodeDataErrorContextStub();
// act
const validator = createNodeDataValidator(context);
// assert
expect(validator).to.be.instanceOf(ContextualNodeDataValidator);
});
});
describe('NodeDataValidator', () => {
describe('assertValidName', () => {
describe('throws when name is invalid', () => {
// arrange
const testScenarios: readonly {
readonly description: string;
readonly invalidName: unknown;
readonly expectedMessage: string;
}[] = [
...getAbsentStringTestCases().map((testCase) => ({
description: `missing name (${testCase.valueName})`,
invalidName: testCase.absentValue,
expectedMessage: 'missing name',
})),
{
description: 'invalid type',
invalidName: 33,
expectedMessage: 'Name (33) is not a string but number.',
},
];
testScenarios.forEach(({ description, invalidName, expectedMessage }) => {
describe(`given "${description}"`, () => {
itThrowsCorrectly({
// act
throwingAction: (sut) => {
sut.assertValidName(invalidName as string);
},
// assert
expectedMessage,
});
});
});
});
it('does not throw when name is valid', () => {
// arrange
const validName = 'validName';
const sut = new NodeValidatorBuilder()
.build();
// act
const act = () => sut.assertValidName(validName);
// assert
expect(act).to.not.throw();
});
});
describe('assertDefined', () => {
describe('throws when node data is missing', () => {
// arrange
const testScenarios: readonly {
readonly description: string;
readonly invalidData: unknown;
}[] = [
...getAbsentObjectTestCases().map((testCase) => ({
description: `absent object (${testCase.valueName})`,
invalidData: testCase.absentValue,
})),
{
description: 'empty object',
invalidData: {},
},
];
testScenarios.forEach(({ description, invalidData }) => {
describe(`given "${description}"`, () => {
const expectedMessage = 'missing node data';
itThrowsCorrectly({
// act
throwingAction: (sut: NodeDataValidator) => {
sut.assertDefined(invalidData as NodeData);
},
// assert
expectedMessage,
});
});
});
});
it('does not throw if node data is defined', () => {
// arrange
const definedNode = new CategoryDataStub();
const sut = new NodeValidatorBuilder()
.build();
// act
const act = () => sut.assertDefined(definedNode);
// assert
expect(act).to.not.throw();
});
});
describe('assert', () => {
describe('throws if validation fails', () => {
const falsePredicate = () => false;
const expectedErrorMessage = 'expected error';
// assert
itThrowsCorrectly({
// act
throwingAction: (sut: NodeDataValidator) => {
sut.assert(falsePredicate, expectedErrorMessage);
},
// assert
expectedMessage: expectedErrorMessage,
});
});
it('does not throw if validation succeeds', () => {
// arrange
const truePredicate = () => true;
const sut = new NodeValidatorBuilder()
.build();
// act
const act = () => sut.assert(truePredicate, 'ignored error');
// assert
expect(act).to.not.throw();
});
});
describe('createContextualErrorMessage', () => {
it('creates using the correct error message', () => {
// arrange
const expectedErrorMessage = 'expected error';
const errorMessageBuilder: NodeContextErrorMessageCreator = (message) => message;
const sut = new NodeValidatorBuilder()
.withErrorMessageCreator(errorMessageBuilder)
.build();
// act
const actualErrorMessage = sut.createContextualErrorMessage(expectedErrorMessage);
// assert
expect(actualErrorMessage).to.equal(expectedErrorMessage);
});
it('creates using the correct context', () => {
// arrange
const expectedContext = createNodeDataErrorContextStub();
let actualContext: NodeDataErrorContext | undefined;
const errorMessageBuilder: NodeContextErrorMessageCreator = (_, context) => {
actualContext = context;
return '';
};
const sut = new NodeValidatorBuilder()
.withContext(expectedContext)
.withErrorMessageCreator(errorMessageBuilder)
.build();
// act
sut.createContextualErrorMessage('unimportant');
// assert
expect(actualContext).to.equal(expectedContext);
});
});
});
type ValidationThrowingFunction = (
sut: ContextualNodeDataValidator,
) => void;
interface ValidationThrowingTestScenario {
readonly throwingAction: ValidationThrowingFunction,
readonly expectedMessage: string;
}
function itThrowsCorrectly(
testScenario: ValidationThrowingTestScenario,
): void {
it('throws an error', () => {
// arrange
const expectedErrorMessage = 'Injected error message';
const errorMessageBuilder: NodeContextErrorMessageCreator = () => expectedErrorMessage;
const sut = new NodeValidatorBuilder()
.withErrorMessageCreator(errorMessageBuilder)
.build();
// act
const action = () => testScenario.throwingAction(sut);
// assert
expect(action).to.throw();
});
it('throws with the correct error message', () => {
// arrange
const expectedErrorMessage = testScenario.expectedMessage;
const errorMessageBuilder: NodeContextErrorMessageCreator = (message) => message;
const sut = new NodeValidatorBuilder()
.withErrorMessageCreator(errorMessageBuilder)
.build();
// act
const action = () => testScenario.throwingAction(sut);
// assert
const actualErrorMessage = collectExceptionMessage(action);
expect(actualErrorMessage).to.equal(expectedErrorMessage);
});
it('throws with the correct context', () => {
// arrange
const expectedContext = createNodeDataErrorContextStub();
const serializeContext = (context: NodeDataErrorContext) => JSON.stringify(context);
const errorMessageBuilder:
NodeContextErrorMessageCreator = (_, context) => serializeContext(context);
const sut = new NodeValidatorBuilder()
.withContext(expectedContext)
.withErrorMessageCreator(errorMessageBuilder)
.build();
// act
const action = () => testScenario.throwingAction(sut);
// assert
const expectedSerializedContext = serializeContext(expectedContext);
const actualSerializedContext = collectExceptionMessage(action);
expect(expectedSerializedContext).to.equal(actualSerializedContext);
});
}
class NodeValidatorBuilder {
private errorContext: NodeDataErrorContext = createNodeDataErrorContextStub();
private errorMessageCreator: NodeContextErrorMessageCreator = () => `[${NodeValidatorBuilder.name}] stub error message`;
public withErrorMessageCreator(errorMessageCreator: NodeContextErrorMessageCreator): this {
this.errorMessageCreator = errorMessageCreator;
return this;
}
public withContext(errorContext: NodeDataErrorContext): this {
this.errorContext = errorContext;
return this;
}
public build(): ContextualNodeDataValidator {
return new ContextualNodeDataValidator(
this.errorContext,
this.errorMessageCreator,
);
}
}

View File

@@ -1,99 +0,0 @@
import { describe, it, expect } from 'vitest';
import { NodeDataError } from '@/application/Parser/NodeValidation/NodeDataError';
import { NodeValidator } from '@/application/Parser/NodeValidation/NodeValidator';
import { expectDeepThrowsError } from '@tests/shared/Assertions/ExpectDeepThrowsError';
import { CategoryDataStub } from '@tests/unit/shared/Stubs/CategoryDataStub';
import { NodeDataErrorContextStub } from '@tests/unit/shared/Stubs/NodeDataErrorContextStub';
import type { NodeData } from '@/application/Parser/NodeValidation/NodeData';
import { NodeValidationTestRunner } from './NodeValidatorTestRunner';
describe('NodeValidator', () => {
describe('assertValidName', () => {
describe('throws if invalid', () => {
// arrange
const context = new NodeDataErrorContextStub();
const sut = new NodeValidator(context);
// act
const act = (invalidName: string) => sut.assertValidName(invalidName);
// assert
new NodeValidationTestRunner()
.testInvalidNodeName((invalidName) => ({
act: () => act(invalidName),
expectedContext: context,
}));
});
it('does not throw if valid', () => {
// arrange
const validName = 'validName';
const sut = new NodeValidator(new NodeDataErrorContextStub());
// act
const act = () => sut.assertValidName(validName);
// assert
expect(act).to.not.throw();
});
});
describe('assertDefined', () => {
describe('throws if missing', () => {
// arrange
const context = new NodeDataErrorContextStub();
const sut = new NodeValidator(context);
// act
const act = (undefinedNode: NodeData) => sut.assertDefined(undefinedNode);
// assert
new NodeValidationTestRunner()
.testMissingNodeData((invalidName) => ({
act: () => act(invalidName),
expectedContext: context,
}));
});
it('does not throw if defined', () => {
// arrange
const definedNode = mockNode();
const sut = new NodeValidator(new NodeDataErrorContextStub());
// act
const act = () => sut.assertDefined(definedNode);
// assert
expect(act).to.not.throw();
});
});
describe('assert', () => {
it('throws expected error if condition is false', () => {
// arrange
const message = 'error';
const falsePredicate = () => false;
const context = new NodeDataErrorContextStub();
const expected = new NodeDataError(message, context);
const sut = new NodeValidator(context);
// act
const act = () => sut.assert(falsePredicate, message);
// assert
expectDeepThrowsError(act, expected);
});
it('does not throw if condition is true', () => {
// arrange
const truePredicate = () => true;
const sut = new NodeValidator(new NodeDataErrorContextStub());
// act
const act = () => sut.assert(truePredicate, 'ignored error');
// assert
expect(act).to.not.throw();
});
});
describe('throw', () => {
it('throws expected error', () => {
// arrange
const message = 'error';
const context = new NodeDataErrorContextStub();
const expected = new NodeDataError(message, context);
const sut = new NodeValidator(context);
// act
const act = () => sut.throw(message);
// assert
expectDeepThrowsError(act, expected);
});
});
});
function mockNode() {
return new CategoryDataStub();
}

View File

@@ -1,87 +0,0 @@
import { describe, it } from 'vitest';
import { NodeDataError, type INodeDataErrorContext } from '@/application/Parser/NodeValidation/NodeDataError';
import type { NodeData } from '@/application/Parser/NodeValidation/NodeData';
import { getAbsentObjectTestCases, getAbsentStringTestCases, itEachAbsentTestCase } from '@tests/unit/shared/TestCases/AbsentTests';
import { expectDeepThrowsError } from '@tests/shared/Assertions/ExpectDeepThrowsError';
export interface ITestScenario {
readonly act: () => void;
readonly expectedContext: INodeDataErrorContext;
}
export class NodeValidationTestRunner {
public testInvalidNodeName(
testBuildPredicate: (invalidName: string) => ITestScenario,
) {
describe('throws given invalid names', () => {
// arrange
const testCases = [
...getAbsentStringTestCases().map((testCase) => ({
testName: `missing name (${testCase.valueName})`,
nameValue: testCase.absentValue,
expectedMessage: 'missing name',
})),
{
testName: 'invalid type',
nameValue: 33,
expectedMessage: 'Name (33) is not a string but number.',
},
];
for (const testCase of testCases) {
it(`given "${testCase.testName}"`, () => {
const test = testBuildPredicate(testCase.nameValue as never);
expectThrowsNodeError(test, testCase.expectedMessage);
});
}
});
return this;
}
public testMissingNodeData(
testBuildPredicate: (missingNode: NodeData) => ITestScenario,
) {
describe('throws given missing node data', () => {
itEachAbsentTestCase([
...getAbsentObjectTestCases(),
{
valueName: 'empty object',
absentValue: {},
},
], (absentValue) => {
// arrange
const expectedError = 'missing node data';
// act
const test = testBuildPredicate(absentValue as NodeData);
// assert
expectThrowsNodeError(test, expectedError);
});
});
return this;
}
public runThrowingCase(
testCase: {
readonly name: string,
readonly scenario: ITestScenario,
readonly expectedMessage: string
},
) {
it(testCase.name, () => {
expectThrowsNodeError(testCase.scenario, testCase.expectedMessage);
});
return this;
}
}
export function expectThrowsNodeError(
test: ITestScenario,
expectedMessage: string,
) {
// arrange
const expected = new NodeDataError(expectedMessage, test.expectedContext);
// act
const act = () => test.act();
// assert
expectDeepThrowsError(act, expected);
return this;
}

View File

@@ -229,7 +229,11 @@ class ExpressionBuilder {
}
public build() {
return new Expression(this.position, this.evaluator, this.parameters);
return new Expression({
position: this.position,
evaluator: this.evaluator,
parameters: this.parameters,
});
}
private evaluator: ExpressionEvaluator = () => `[${ExpressionBuilder.name}] evaluated-expression`;

View File

@@ -1,22 +1,21 @@
import { describe, it, expect } from 'vitest';
import { createPositionFromRegexFullMatch } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPositionFactory';
import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition';
import { itIsTransientFactory } from '@tests/unit/shared/TestCases/TransientFactoryTests';
describe('ExpressionPositionFactory', () => {
describe('createPositionFromRegexFullMatch', () => {
it(`creates ${ExpressionPosition.name} instance`, () => {
describe('it is a transient factory', () => {
// arrange
const expectedType = ExpressionPosition;
const fakeMatch = createRegexMatch({
fullMatch: 'matched string',
matchIndex: 5,
});
const fakeMatch = createRegexMatch();
// act
const position = createPositionFromRegexFullMatch(fakeMatch);
const create = () => createPositionFromRegexFullMatch(fakeMatch);
// assert
expect(position).to.be.instanceOf(expectedType);
itIsTransientFactory({
getter: create,
expectedType: ExpressionPosition,
});
});
it('creates a position with the correct start position', () => {
// arrange
const expectedStartPosition = 5;
@@ -63,10 +62,8 @@ describe('ExpressionPositionFactory', () => {
describe('invalid values', () => {
it('throws an error if match.index is undefined', () => {
// arrange
const fakeMatch = createRegexMatch({
fullMatch: 'matched string',
matchIndex: undefined,
});
const fakeMatch = createRegexMatch();
fakeMatch.index = undefined;
const expectedError = `Regex match did not yield any results: ${JSON.stringify(fakeMatch)}`;
// act
const act = () => createPositionFromRegexFullMatch(fakeMatch);
@@ -94,9 +91,9 @@ function createRegexMatch(options?: {
readonly capturingGroups?: readonly string[],
readonly matchIndex?: number,
}): RegExpMatchArray {
const fullMatch = options?.fullMatch ?? 'fake match';
const fullMatch = options?.fullMatch ?? 'default fake match';
const capturingGroups = options?.capturingGroups ?? [];
const fakeMatch: RegExpMatchArray = [fullMatch, ...capturingGroups];
fakeMatch.index = options?.matchIndex;
fakeMatch.index = options?.matchIndex ?? 0;
return fakeMatch;
}

View File

@@ -1,168 +1,438 @@
import { describe, it, expect } from 'vitest';
import type { ExpressionEvaluator } from '@/application/Parser/Script/Compiler/Expressions/Expression/Expression';
import { type IPrimitiveExpression, RegexParser } from '@/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser';
import type {
ExpressionEvaluator, ExpressionInitParameters,
} from '@/application/Parser/Script/Compiler/Expressions/Expression/Expression';
import {
type PrimitiveExpression, RegexParser, type ExpressionFactory, type RegexParserUtilities,
} from '@/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser';
import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition';
import { FunctionParameterStub } from '@tests/unit/shared/Stubs/FunctionParameterStub';
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
import { itThrowsContextualError } from '@tests/unit/application/Parser/ContextualErrorTester';
import { ExpressionStub } from '@tests/unit/shared/Stubs/ExpressionStub';
import { FunctionParameterCollectionStub } from '@tests/unit/shared/Stubs/FunctionParameterCollectionStub';
import type { IExpression } from '@/application/Parser/Script/Compiler/Expressions/Expression/IExpression';
import { FunctionParameterStub } from '@tests/unit/shared/Stubs/FunctionParameterStub';
import type { IReadOnlyFunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/IFunctionParameterCollection';
import type { ExpressionPositionFactory } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPositionFactory';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { indentText } from '@tests/shared/Text';
import type { FunctionParameterCollectionFactory } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory';
describe('RegexParser', () => {
describe('findExpressions', () => {
describe('throws when code is absent', () => {
itEachAbsentStringValue((absentValue) => {
describe('error handling', () => {
describe('throws when code is absent', () => {
itEachAbsentStringValue((absentValue) => {
// arrange
const expectedError = 'missing code';
const sut = new RegexParserConcrete({
regex: /unimportant/,
});
// act
const act = () => sut.findExpressions(absentValue);
// assert
const errorMessage = collectExceptionMessage(act);
expect(errorMessage).to.include(expectedError);
}, { excludeNull: true, excludeUndefined: true });
});
describe('rethrows regex match errors', () => {
// arrange
const expectedError = 'missing code';
const sut = new RegexParserConcrete(/unimportant/);
// act
const act = () => sut.findExpressions(absentValue);
// assert
expect(act).to.throw(expectedError);
}, { excludeNull: true, excludeUndefined: true });
const expectedMatchError = new TypeError('String.prototype.matchAll called with a non-global RegExp argument');
const expectedMessage = 'Failed to match regex.';
const expectedCodeInMessage = 'unimportant code content';
const expectedRegexInMessage = /failing-regex-because-it-is-non-global/;
const expectedErrorMessage = buildRethrowErrorMessage({
message: expectedMessage,
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
});
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
const sut = new RegexParserConcrete(
{
regex: expectedRegexInMessage,
utilities: {
wrapError,
},
},
);
sut.findExpressions(expectedCodeInMessage);
},
// assert
expectedContextMessage: expectedErrorMessage,
expectedWrappedError: expectedMatchError,
});
});
describe('rethrows expression building errors', () => {
// arrange
const expectedMessage = 'Failed to build expression.';
const expectedInnerError = new Error('Expected error from building expression');
const {
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
} = createCodeAndRegexMatchingOnce();
const throwingExpressionBuilder = () => {
throw expectedInnerError;
};
const expectedErrorMessage = buildRethrowErrorMessage({
message: expectedMessage,
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
});
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
const sut = new RegexParserConcrete(
{
regex: expectedRegexInMessage,
builder: throwingExpressionBuilder,
utilities: {
wrapError,
},
},
);
sut.findExpressions(expectedCodeInMessage);
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
describe('rethrows position creation errors', () => {
// arrange
const expectedMessage = 'Failed to create position.';
const expectedInnerError = new Error('Expected error from position factory');
const {
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
} = createCodeAndRegexMatchingOnce();
const throwingPositionFactory = () => {
throw expectedInnerError;
};
const expectedErrorMessage = buildRethrowErrorMessage({
message: expectedMessage,
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
});
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
const sut = new RegexParserConcrete(
{
regex: expectedRegexInMessage,
utilities: {
createPosition: throwingPositionFactory,
wrapError,
},
},
);
sut.findExpressions(expectedCodeInMessage);
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
describe('rethrows parameter creation errors', () => {
// arrange
const expectedMessage = 'Failed to create parameters.';
const expectedInnerError = new Error('Expected error from parameter collection factory');
const {
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
} = createCodeAndRegexMatchingOnce();
const throwingParameterCollectionFactory = () => {
throw expectedInnerError;
};
const expectedErrorMessage = buildRethrowErrorMessage({
message: expectedMessage,
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
});
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
const sut = new RegexParserConcrete(
{
regex: expectedRegexInMessage,
utilities: {
createParameterCollection: throwingParameterCollectionFactory,
wrapError,
},
},
);
sut.findExpressions(expectedCodeInMessage);
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
describe('rethrows expression creation errors', () => {
// arrange
const expectedMessage = 'Failed to create expression.';
const expectedInnerError = new Error('Expected error from expression factory');
const {
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
} = createCodeAndRegexMatchingOnce();
const throwingExpressionFactory = () => {
throw expectedInnerError;
};
const expectedErrorMessage = buildRethrowErrorMessage({
message: expectedMessage,
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
});
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
const sut = new RegexParserConcrete(
{
regex: expectedRegexInMessage,
utilities: {
createExpression: throwingExpressionFactory,
wrapError,
},
},
);
sut.findExpressions(expectedCodeInMessage);
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
});
it('throws when position is invalid', () => {
describe('handles matched regex correctly', () => {
// arrange
const regexMatchingEmpty = /^/gm; /* expressions cannot be empty */
const code = 'unimportant';
const expectedErrorParts = [
`[${RegexParserConcrete.constructor.name}]`,
'invalid script position',
`Regex: ${regexMatchingEmpty}`,
`Code: ${code}`,
];
const sut = new RegexParserConcrete(regexMatchingEmpty);
// act
let errorMessage: string | undefined;
try {
sut.findExpressions(code);
} catch (err) {
errorMessage = err.message;
}
// assert
expectExists(errorMessage);
const error = errorMessage; // workaround for ts(18048): possibly 'undefined'
expect(
expectedErrorParts.every((part) => error.includes(part)),
`Expected parts: ${expectedErrorParts.join(', ')}`
+ `Actual error: ${errorMessage}`,
);
});
describe('matches regex as expected', () => {
// arrange
const testCases = [
const testScenarios: readonly {
readonly description: string;
readonly regex: RegExp;
readonly code: string;
}[] = [
{
name: 'returns no result when regex does not match',
description: 'non-matching regex',
regex: /hello/g,
code: 'world',
},
{
name: 'returns expected when regex matches single',
description: 'single regex match',
regex: /hello/g,
code: 'hello world',
},
{
name: 'returns expected when regex matches multiple',
description: 'multiple regex matches',
regex: /l/g,
code: 'hello world',
},
];
for (const testCase of testCases) {
it(testCase.name, () => {
const expected = Array.from(testCase.code.matchAll(testCase.regex));
const matches = new Array<RegExpMatchArray>();
const builder = (m: RegExpMatchArray): IPrimitiveExpression => {
matches.push(m);
return mockPrimitiveExpression();
};
const sut = new RegexParserConcrete(testCase.regex, builder);
// act
const expressions = sut.findExpressions(testCase.code);
// assert
expect(expressions).to.have.lengthOf(matches.length);
expect(matches).to.deep.equal(expected);
testScenarios.forEach(({
description, code, regex,
}) => {
describe(description, () => {
it('generates expressions for all matches', () => {
// arrange
const expectedTotalExpressions = Array.from(code.matchAll(regex)).length;
const sut = new RegexParserConcrete({
regex,
});
// act
const expressions = sut.findExpressions(code);
// assert
const actualTotalExpressions = expressions.length;
expect(actualTotalExpressions).to.equal(
expectedTotalExpressions,
formatAssertionMessage([
`Expected ${actualTotalExpressions} expressions due to ${expectedTotalExpressions} matches`,
`Expressions:\n${indentText(JSON.stringify(expressions, undefined, 2))}`,
]),
);
});
it('builds primitive expressions for each match', () => {
const expected = Array.from(code.matchAll(regex));
const matches = new Array<RegExpMatchArray>();
const builder = (m: RegExpMatchArray): PrimitiveExpression => {
matches.push(m);
return createPrimitiveExpressionStub();
};
const sut = new RegexParserConcrete({
regex,
builder,
});
// act
sut.findExpressions(code);
// assert
expect(matches).to.deep.equal(expected);
});
it('sets positions correctly from matches', () => {
// arrange
const expectedMatches = [...code.matchAll(regex)];
const { createExpression, getInitParameters } = createExpressionFactorySpy();
const serializeRegexMatch = (match: RegExpMatchArray) => `[startPos:${match?.index ?? 'none'},length:${match?.[0]?.length ?? 'none'}]`;
const positionsForMatches = new Map<string, ExpressionPosition>(expectedMatches.map(
(expectedMatch) => [serializeRegexMatch(expectedMatch), new ExpressionPosition(1, 4)],
));
const createPositionMock: ExpressionPositionFactory = (match) => {
const position = positionsForMatches.get(serializeRegexMatch(match));
return position ?? new ExpressionPosition(66, 666);
};
const sut = new RegexParserConcrete({
regex,
utilities: {
createExpression,
createPosition: createPositionMock,
},
});
// act
const expressions = sut.findExpressions(code);
// assert
const expectedPositions = [...positionsForMatches.values()];
const actualPositions = expressions.map((e) => getInitParameters(e)?.position);
expect(actualPositions).to.deep.equal(expectedPositions, formatAssertionMessage([
'Actual positions do not match the expected positions.',
`Expected total positions: ${expectedPositions.length} (due to ${expectedMatches.length} regex matches)`,
`Actual total positions: ${actualPositions.length}`,
`Expected positions:\n${indentText(JSON.stringify(expectedPositions, undefined, 2))}`,
`Actual positions:\n${indentText(JSON.stringify(actualPositions, undefined, 2))}`,
]));
});
});
}
});
it('sets evaluator as expected', () => {
// arrange
const expected = getEvaluatorStub();
const regex = /hello/g;
const code = 'hello';
const builder = (): IPrimitiveExpression => ({
evaluator: expected,
});
const sut = new RegexParserConcrete(regex, builder);
});
it('sets evaluator correctly from expression', () => {
// arrange
const { createExpression, getInitParameters } = createExpressionFactorySpy();
const expectedEvaluate = createEvaluatorStub();
const { code, regex } = createCodeAndRegexMatchingOnce();
const builder = (): PrimitiveExpression => ({
evaluator: expectedEvaluate,
});
const sut = new RegexParserConcrete({
regex,
builder,
utilities: {
createExpression,
},
});
// act
const expressions = sut.findExpressions(code);
// assert
expect(expressions).to.have.lengthOf(1);
expect(expressions[0].evaluate === expected);
const actualEvaluate = getInitParameters(expressions[0])?.evaluator;
expect(actualEvaluate).to.equal(expectedEvaluate);
});
it('sets parameters as expected', () => {
it('sets parameters correctly from expression', () => {
// arrange
const expected = [
new FunctionParameterStub().withName('parameter1').withOptionality(true),
new FunctionParameterStub().withName('parameter2').withOptionality(false),
const expectedParameters: IReadOnlyFunctionParameterCollection['all'] = [
new FunctionParameterStub().withName('parameter1').withOptional(true),
new FunctionParameterStub().withName('parameter2').withOptional(false),
];
const regex = /hello/g;
const code = 'hello';
const builder = (): IPrimitiveExpression => ({
evaluator: getEvaluatorStub(),
parameters: expected,
const builder = (): PrimitiveExpression => ({
evaluator: createEvaluatorStub(),
parameters: expectedParameters,
});
const parameterCollection = new FunctionParameterCollectionStub();
const parameterCollectionFactoryStub
: FunctionParameterCollectionFactory = () => parameterCollection;
const { createExpression, getInitParameters } = createExpressionFactorySpy();
const sut = new RegexParserConcrete({
regex,
builder,
utilities: {
createExpression,
createParameterCollection: parameterCollectionFactoryStub,
},
});
const sut = new RegexParserConcrete(regex, builder);
// act
const expressions = sut.findExpressions(code);
// assert
expect(expressions).to.have.lengthOf(1);
expect(expressions[0].parameters.all).to.deep.equal(expected);
});
it('sets expected position', () => {
// arrange
const code = 'mate date in state is fate';
const regex = /ate/g;
const expected = [
new ExpressionPosition(1, 4),
new ExpressionPosition(6, 9),
new ExpressionPosition(15, 18),
new ExpressionPosition(23, 26),
];
const sut = new RegexParserConcrete(regex);
// act
const expressions = sut.findExpressions(code);
// assert
const actual = expressions.map((e) => e.position);
expect(actual).to.deep.equal(expected);
const actualParameters = getInitParameters(expressions[0])?.parameters;
expect(actualParameters).to.equal(parameterCollection);
expect(actualParameters?.all).to.deep.equal(expectedParameters);
});
});
});
function mockBuilder(): (match: RegExpMatchArray) => IPrimitiveExpression {
return () => ({
evaluator: getEvaluatorStub(),
});
}
function getEvaluatorStub(): ExpressionEvaluator {
return () => `[${getEvaluatorStub.name}] evaluated code`;
function buildRethrowErrorMessage(
expectedContext: {
readonly message: string;
readonly regex: RegExp;
readonly code: string;
},
): string {
return [
expectedContext.message,
`Class name: ${RegexParserConcrete.name}`,
`Regex pattern used: ${expectedContext.regex}`,
`Code: ${expectedContext.code}`,
].join('\n');
}
function mockPrimitiveExpression(): IPrimitiveExpression {
function createExpressionFactorySpy() {
const createdExpressions = new Map<IExpression, ExpressionInitParameters>();
const createExpression: ExpressionFactory = (parameters) => {
const expression = new ExpressionStub();
createdExpressions.set(expression, parameters);
return expression;
};
return {
evaluator: getEvaluatorStub(),
createExpression,
getInitParameters: (expression) => createdExpressions.get(expression),
};
}
function createBuilderStub(): (match: RegExpMatchArray) => PrimitiveExpression {
return () => ({
evaluator: createEvaluatorStub(),
});
}
function createEvaluatorStub(): ExpressionEvaluator {
return () => `[${createEvaluatorStub.name}] evaluated code`;
}
function createPrimitiveExpressionStub(): PrimitiveExpression {
return {
evaluator: createEvaluatorStub(),
};
}
function createCodeAndRegexMatchingOnce() {
const code = 'expected code in context';
const regex = /code/g;
return { code, regex };
}
class RegexParserConcrete extends RegexParser {
private readonly builder: RegexParser['buildExpression'];
protected regex: RegExp;
public constructor(
regex: RegExp,
private readonly builder = mockBuilder(),
) {
super();
this.regex = regex;
public constructor(parameters?: {
regex?: RegExp,
builder?: RegexParser['buildExpression'],
utilities?: Partial<RegexParserUtilities>,
}) {
super({
wrapError: parameters?.utilities?.wrapError
?? (() => new Error(`[${RegexParserConcrete}] wrapped error`)),
createPosition: parameters?.utilities?.createPosition
?? (() => new ExpressionPosition(0, 5)),
createExpression: parameters?.utilities?.createExpression
?? (() => new ExpressionStub()),
createParameterCollection: parameters?.utilities?.createParameterCollection
?? (() => new FunctionParameterCollectionStub()),
});
this.builder = parameters?.builder ?? createBuilderStub();
this.regex = parameters?.regex ?? /unimportant/g;
}
protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression {
protected buildExpression(match: RegExpMatchArray): PrimitiveExpression {
return this.builder(match);
}
}

View File

@@ -17,7 +17,7 @@ describe('FunctionCallArgument', () => {
itEachAbsentStringValue((absentValue) => {
// arrange
const parameterName = 'paramName';
const expectedError = `missing argument value for "${parameterName}"`;
const expectedError = `Missing argument value for the parameter "${parameterName}".`;
const argumentValue = absentValue;
// act
const act = () => new FunctionCallArgumentBuilder()

View File

@@ -1,7 +1,7 @@
/* eslint-disable max-classes-per-file */
import { describe, it, expect } from 'vitest';
import { FunctionCallSequenceCompiler } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallSequenceCompiler';
import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests';
import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests';
import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests';
import type { SingleCallCompiler } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/SingleCallCompiler';
import type { CodeSegmentMerger } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/CodeSegmentJoin/CodeSegmentMerger';
@@ -17,7 +17,7 @@ import { expectExists } from '@tests/shared/Assertions/ExpectExists';
describe('FunctionCallSequenceCompiler', () => {
describe('instance', () => {
itIsSingleton({
itIsSingletonFactory({
getter: () => FunctionCallSequenceCompiler.instance,
expectedType: FunctionCallSequenceCompiler,
});

View File

@@ -9,7 +9,9 @@ import { SingleCallCompilerStub } from '@tests/unit/shared/Stubs/SingleCallCompi
import { CompiledCodeStub } from '@tests/unit/shared/Stubs/CompiledCodeStub';
import type { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall';
import type { CompiledCode } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/CompiledCode';
import { expectDeepThrowsError } from '@tests/shared/Assertions/ExpectDeepThrowsError';
import { itThrowsContextualError } from '@tests/unit/application/Parser/ContextualErrorTester';
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub';
describe('NestedFunctionCallCompiler', () => {
describe('canCompile', () => {
@@ -43,12 +45,12 @@ describe('NestedFunctionCallCompiler', () => {
// arrange
const argumentCompiler = new ArgumentCompilerStub();
const expectedContext = new FunctionCallCompilationContextStub();
const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const compiler = new NestedFunctionCallCompilerBuilder()
.withArgumentCompiler(argumentCompiler)
.build();
// act
compiler.compileFunction(frontFunc, callToFrontFunc, expectedContext);
compiler.compileFunction(frontFunction, callToFrontFunc, expectedContext);
// assert
const calls = argumentCompiler.callHistory.filter((call) => call.methodName === 'createCompiledNestedCall');
expect(calls).have.lengthOf(1);
@@ -59,33 +61,37 @@ describe('NestedFunctionCallCompiler', () => {
// arrange
const argumentCompiler = new ArgumentCompilerStub();
const expectedContext = new FunctionCallCompilationContextStub();
const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const expectedParentCall = callToFrontFunc;
const compiler = new NestedFunctionCallCompilerBuilder()
.withArgumentCompiler(argumentCompiler)
.build();
// act
compiler.compileFunction(frontFunc, callToFrontFunc, expectedContext);
compiler.compileFunction(frontFunction, callToFrontFunc, expectedContext);
// assert
const calls = argumentCompiler.callHistory.filter((call) => call.methodName === 'createCompiledNestedCall');
expect(calls).have.lengthOf(1);
const [,actualParentCall] = calls[0].args;
expect(actualParentCall).to.equal(callToFrontFunc);
expect(actualParentCall).to.equal(expectedParentCall);
});
it('uses correct nested call', () => {
// arrange
const argumentCompiler = new ArgumentCompilerStub();
const expectedContext = new FunctionCallCompilationContextStub();
const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const {
frontFunction, callToDeepFunc, callToFrontFunc,
} = createSingleFuncCallingAnotherFunc();
const expectedNestedCall = callToDeepFunc;
const compiler = new NestedFunctionCallCompilerBuilder()
.withArgumentCompiler(argumentCompiler)
.build();
// act
compiler.compileFunction(frontFunc, callToFrontFunc, expectedContext);
compiler.compileFunction(frontFunction, callToFrontFunc, expectedContext);
// assert
const calls = argumentCompiler.callHistory.filter((call) => call.methodName === 'createCompiledNestedCall');
expect(calls).have.lengthOf(1);
const [actualNestedCall] = calls[0].args;
expect(actualNestedCall).to.deep.equal(callToFrontFunc);
expect(actualNestedCall).to.deep.equal(expectedNestedCall);
});
});
describe('re-compilation with compiled args', () => {
@@ -94,11 +100,11 @@ describe('NestedFunctionCallCompiler', () => {
const singleCallCompilerStub = new SingleCallCompilerStub();
const expectedContext = new FunctionCallCompilationContextStub()
.withSingleCallCompiler(singleCallCompilerStub);
const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const compiler = new NestedFunctionCallCompilerBuilder()
.build();
// act
compiler.compileFunction(frontFunc, callToFrontFunc, expectedContext);
compiler.compileFunction(frontFunction, callToFrontFunc, expectedContext);
// assert
const calls = singleCallCompilerStub.callHistory.filter((call) => call.methodName === 'compileSingleCall');
expect(calls).have.lengthOf(1);
@@ -113,12 +119,12 @@ describe('NestedFunctionCallCompiler', () => {
const singleCallCompilerStub = new SingleCallCompilerStub();
const context = new FunctionCallCompilationContextStub()
.withSingleCallCompiler(singleCallCompilerStub);
const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const compiler = new NestedFunctionCallCompilerBuilder()
.withArgumentCompiler(argumentCompilerStub)
.build();
// act
compiler.compileFunction(frontFunc, callToFrontFunc, context);
compiler.compileFunction(frontFunction, callToFrontFunc, context);
// assert
const calls = singleCallCompilerStub.callHistory.filter((call) => call.methodName === 'compileSingleCall');
expect(calls).have.lengthOf(1);
@@ -140,9 +146,9 @@ describe('NestedFunctionCallCompiler', () => {
.withScenario({ givenNestedFunctionCall: callToDeepFunc1, result: callToDeepFunc1 })
.withScenario({ givenNestedFunctionCall: callToDeepFunc2, result: callToDeepFunc2 });
const expectedFlattenedCodes = [...singleCallCompilationScenario.values()].flat();
const frontFunc = createSharedFunctionStubWithCalls()
const frontFunction = createSharedFunctionStubWithCalls()
.withCalls(callToDeepFunc1, callToDeepFunc2);
const callToFrontFunc = new FunctionCallStub().withFunctionName(frontFunc.name);
const callToFrontFunc = new FunctionCallStub().withFunctionName(frontFunction.name);
const singleCallCompilerStub = new SingleCallCompilerStub()
.withCallCompilationScenarios(singleCallCompilationScenario);
const expectedContext = new FunctionCallCompilationContextStub()
@@ -151,73 +157,105 @@ describe('NestedFunctionCallCompiler', () => {
.withArgumentCompiler(argumentCompiler)
.build();
// act
const actualCodes = compiler.compileFunction(frontFunc, callToFrontFunc, expectedContext);
const actualCodes = compiler.compileFunction(frontFunction, callToFrontFunc, expectedContext);
// assert
expect(actualCodes).have.lengthOf(expectedFlattenedCodes.length);
expect(actualCodes).to.have.members(expectedFlattenedCodes);
});
describe('error handling', () => {
it('handles argument compiler errors', () => {
describe('rethrows error from argument compiler', () => {
// arrange
const argumentCompilerError = new Error('Test error');
const expectedInnerError = new Error(`Expected error from ${ArgumentCompilerStub.name}`);
const calleeFunctionName = 'expectedCalleeFunctionName';
const callerFunctionName = 'expectedCallerFunctionName';
const expectedErrorMessage = buildRethrowErrorMessage({
callee: calleeFunctionName,
caller: callerFunctionName,
});
const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc({
frontFunctionName: callerFunctionName,
deepFunctionName: calleeFunctionName,
});
const argumentCompilerStub = new ArgumentCompilerStub();
argumentCompilerStub.createCompiledNestedCall = () => {
throw argumentCompilerError;
throw expectedInnerError;
};
const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const expectedError = new AggregateError(
[argumentCompilerError],
`Error with call to "${callToFrontFunc.functionName}" function from "${callToFrontFunc.functionName}" function`,
);
const compiler = new NestedFunctionCallCompilerBuilder()
.withArgumentCompiler(argumentCompilerStub)
.build();
// act
const act = () => compiler.compileFunction(
frontFunc,
callToFrontFunc,
new FunctionCallCompilationContextStub(),
);
// assert
expectDeepThrowsError(act, expectedError);
const builder = new NestedFunctionCallCompilerBuilder()
.withArgumentCompiler(argumentCompilerStub);
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
builder
.withErrorWrapper(wrapError)
.build()
.compileFunction(
frontFunction,
callToFrontFunc,
new FunctionCallCompilationContextStub(),
);
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
it('handles single call compiler errors', () => {
describe('rethrows error from single call compiler', () => {
// arrange
const singleCallCompilerError = new Error('Test error');
const expectedInnerError = new Error(`Expected error from ${SingleCallCompilerStub.name}`);
const calleeFunctionName = 'expectedCalleeFunctionName';
const callerFunctionName = 'expectedCallerFunctionName';
const expectedErrorMessage = buildRethrowErrorMessage({
callee: calleeFunctionName,
caller: callerFunctionName,
});
const { frontFunction, callToFrontFunc } = createSingleFuncCallingAnotherFunc({
frontFunctionName: callerFunctionName,
deepFunctionName: calleeFunctionName,
});
const singleCallCompiler = new SingleCallCompilerStub();
singleCallCompiler.compileSingleCall = () => {
throw singleCallCompilerError;
throw expectedInnerError;
};
const context = new FunctionCallCompilationContextStub()
.withSingleCallCompiler(singleCallCompiler);
const { frontFunc, callToFrontFunc } = createSingleFuncCallingAnotherFunc();
const expectedError = new AggregateError(
[singleCallCompilerError],
`Error with call to "${callToFrontFunc.functionName}" function from "${callToFrontFunc.functionName}" function`,
);
const compiler = new NestedFunctionCallCompilerBuilder()
.build();
// act
const act = () => compiler.compileFunction(
frontFunc,
callToFrontFunc,
context,
);
// assert
expectDeepThrowsError(act, expectedError);
const builder = new NestedFunctionCallCompilerBuilder();
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
builder
.withErrorWrapper(wrapError)
.build()
.compileFunction(
frontFunction,
callToFrontFunc,
context,
);
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
});
});
});
function createSingleFuncCallingAnotherFunc() {
const deepFunc = createSharedFunctionStubWithCode();
const callToDeepFunc = new FunctionCallStub().withFunctionName(deepFunc.name);
const frontFunc = createSharedFunctionStubWithCalls().withCalls(callToDeepFunc);
const callToFrontFunc = new FunctionCallStub().withFunctionName(frontFunc.name);
function createSingleFuncCallingAnotherFunc(
functionNames?: {
readonly frontFunctionName?: string;
readonly deepFunctionName?: string;
},
) {
const deepFunction = createSharedFunctionStubWithCode()
.withName(functionNames?.deepFunctionName ?? 'deep-function (is called by front-function)');
const callToDeepFunc = new FunctionCallStub().withFunctionName(deepFunction.name);
const frontFunction = createSharedFunctionStubWithCalls()
.withCalls(callToDeepFunc)
.withName(functionNames?.frontFunctionName ?? 'front-function (calls deep-function)');
const callToFrontFunc = new FunctionCallStub().withFunctionName(frontFunction.name);
return {
deepFunc,
frontFunc,
deepFunction,
frontFunction,
callToFrontFunc,
callToDeepFunc,
};
@@ -226,14 +264,31 @@ function createSingleFuncCallingAnotherFunc() {
class NestedFunctionCallCompilerBuilder {
private argumentCompiler: ArgumentCompiler = new ArgumentCompilerStub();
private wrapError: ErrorWithContextWrapper = errorWithContextWrapperStub;
public withArgumentCompiler(argumentCompiler: ArgumentCompiler): this {
this.argumentCompiler = argumentCompiler;
return this;
}
public withErrorWrapper(wrapError: ErrorWithContextWrapper): this {
this.wrapError = wrapError;
return this;
}
public build(): NestedFunctionCallCompiler {
return new NestedFunctionCallCompiler(
this.argumentCompiler,
this.wrapError,
);
}
}
function buildRethrowErrorMessage(
functionNames: {
readonly caller: string;
readonly callee: string;
},
) {
return `Failed to call '${functionNames.callee}' (callee function) from '${functionNames.caller}' (caller function).`;
}

View File

@@ -11,6 +11,7 @@ import type { FunctionCallCompilationContext } from '@/application/Parser/Script
import { FunctionCallCompilationContextStub } from '@tests/unit/shared/Stubs/FunctionCallCompilationContextStub';
import { CompiledCodeStub } from '@tests/unit/shared/Stubs/CompiledCodeStub';
import type { SingleCallCompiler } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/SingleCall/SingleCallCompiler';
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
describe('AdaptiveFunctionCallCompiler', () => {
describe('compileSingleCall', () => {
@@ -28,40 +29,40 @@ describe('AdaptiveFunctionCallCompiler', () => {
functionParameters: ['expected-parameter'],
callParameters: ['unexpected-parameter'],
expectedError:
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter"`
+ '. Expected parameter(s): "expected-parameter"',
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter".`
+ '\nExpected parameter(s): "expected-parameter"',
},
{
description: 'provided: multiple unexpected parameters, when: different one is expected',
functionParameters: ['expected-parameter'],
callParameters: ['unexpected-parameter1', 'unexpected-parameter2'],
expectedError:
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter1", "unexpected-parameter2"`
+ '. Expected parameter(s): "expected-parameter"',
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter1", "unexpected-parameter2".`
+ '\nExpected parameter(s): "expected-parameter"',
},
{
description: 'provided: an unexpected parameter, when: multiple parameters are expected',
functionParameters: ['expected-parameter1', 'expected-parameter2'],
callParameters: ['expected-parameter1', 'expected-parameter2', 'unexpected-parameter'],
expectedError:
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter"`
+ '. Expected parameter(s): "expected-parameter1", "expected-parameter2"',
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter".`
+ '\nExpected parameter(s): "expected-parameter1", "expected-parameter2"',
},
{
description: 'provided: an unexpected parameter, when: none required',
functionParameters: [],
callParameters: ['unexpected-call-parameter'],
expectedError:
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-call-parameter"`
+ '. Expected parameter(s): none',
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-call-parameter".`
+ '\nExpected parameter(s): none',
},
{
description: 'provided: expected and unexpected parameter, when: one of them is expected',
functionParameters: ['expected-parameter'],
callParameters: ['expected-parameter', 'unexpected-parameter'],
expectedError:
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter"`
+ '. Expected parameter(s): "expected-parameter"',
`Function "${functionName}" has unexpected parameter(s) provided: "unexpected-parameter".`
+ '\nExpected parameter(s): "expected-parameter"',
},
];
testCases.forEach(({
@@ -88,7 +89,8 @@ describe('AdaptiveFunctionCallCompiler', () => {
// act
const act = () => builder.compileSingleCall();
// assert
expect(act).to.throw(expectedError);
const errorMessage = collectExceptionMessage(act);
expect(errorMessage).to.include(expectedError);
});
});
});

View File

@@ -7,38 +7,44 @@ import type { IExpressionsCompiler } from '@/application/Parser/Script/Compiler/
import { ExpressionsCompilerStub } from '@tests/unit/shared/Stubs/ExpressionsCompilerStub';
import { FunctionCallCompilationContextStub } from '@tests/unit/shared/Stubs/FunctionCallCompilationContextStub';
import { FunctionCallStub } from '@tests/unit/shared/Stubs/FunctionCallStub';
import { expectDeepThrowsError } from '@tests/shared/Assertions/ExpectDeepThrowsError';
import { FunctionCallArgumentCollectionStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentCollectionStub';
import { createSharedFunctionStubWithCode } from '@tests/unit/shared/Stubs/SharedFunctionStub';
import { FunctionParameterCollectionStub } from '@tests/unit/shared/Stubs/FunctionParameterCollectionStub';
import { SharedFunctionCollectionStub } from '@tests/unit/shared/Stubs/SharedFunctionCollectionStub';
import { itThrowsContextualError } from '@tests/unit/application/Parser/ContextualErrorTester';
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub';
describe('NestedFunctionArgumentCompiler', () => {
describe('createCompiledNestedCall', () => {
it('should handle error from expressions compiler', () => {
describe('rethrows error from expressions compiler', () => {
// arrange
const expectedInnerError = new Error('child-');
const parameterName = 'parameterName';
const expectedErrorMessage = `Error when compiling argument for "${parameterName}"`;
const nestedCall = new FunctionCallStub()
.withFunctionName('nested-function-call')
.withArgumentCollection(new FunctionCallArgumentCollectionStub()
.withArgument(parameterName, 'unimportant-value'));
const parentCall = new FunctionCallStub()
.withFunctionName('parent-function-call');
const expressionsCompilerError = new Error('child-');
const expectedError = new AggregateError(
[expressionsCompilerError],
`Error when compiling argument for "${parameterName}"`,
);
const expressionsCompiler = new ExpressionsCompilerStub();
expressionsCompiler.compileExpressions = () => { throw expressionsCompilerError; };
expressionsCompiler.compileExpressions = () => { throw expectedInnerError; };
const builder = new NestedFunctionArgumentCompilerBuilder()
.withParentFunctionCall(parentCall)
.withNestedFunctionCall(nestedCall)
.withExpressionsCompiler(expressionsCompiler);
// act
const act = () => builder.createCompiledNestedCall();
// assert
expectDeepThrowsError(act, expectedError);
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
builder
.withErrorWrapper(wrapError)
.createCompiledNestedCall();
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
describe('compilation', () => {
describe('without arguments', () => {
@@ -258,6 +264,8 @@ class NestedFunctionArgumentCompilerBuilder implements ArgumentCompiler {
private context: FunctionCallCompilationContext = new FunctionCallCompilationContextStub();
private wrapError: ErrorWithContextWrapper = errorWithContextWrapperStub;
public withExpressionsCompiler(expressionsCompiler: IExpressionsCompiler): this {
this.expressionsCompiler = expressionsCompiler;
return this;
@@ -278,8 +286,16 @@ class NestedFunctionArgumentCompilerBuilder implements ArgumentCompiler {
return this;
}
public withErrorWrapper(wrapError: ErrorWithContextWrapper): this {
this.wrapError = wrapError;
return this;
}
public createCompiledNestedCall(): FunctionCall {
const compiler = new NestedFunctionArgumentCompiler(this.expressionsCompiler);
const compiler = new NestedFunctionArgumentCompiler(
this.expressionsCompiler,
this.wrapError,
);
return compiler.createCompiledNestedCall(
this.nestedFunctionCall,
this.parentFunctionCall,

View File

@@ -7,8 +7,8 @@ describe('FunctionParameterCollection', () => {
// arrange
const expected = [
new FunctionParameterStub().withName('1'),
new FunctionParameterStub().withName('2').withOptionality(true),
new FunctionParameterStub().withName('3').withOptionality(false),
new FunctionParameterStub().withName('2').withOptional(true),
new FunctionParameterStub().withName('3').withOptional(false),
];
const sut = new FunctionParameterCollection();
for (const parameter of expected) {

View File

@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { FunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollection';
import { createFunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory';
import { itIsTransientFactory } from '@tests/unit/shared/TestCases/TransientFactoryTests';
describe('FunctionParameterCollectionFactory', () => {
describe('createFunctionParameterCollection', () => {
describe('it is a transient factory', () => {
itIsTransientFactory({
getter: () => createFunctionParameterCollection(),
expectedType: FunctionParameterCollection,
});
});
it('returns an empty collection', () => {
// arrange
const expectedInitialParametersCount = 0;
// act
const collection = createFunctionParameterCollection();
// assert
expect(collection.all).to.have.lengthOf(expectedInitialParametersCount);
});
});
});

View File

@@ -35,7 +35,7 @@ describe('SharedFunctionCollection', () => {
it('throws if function does not exist', () => {
// arrange
const name = 'unique-name';
const expectedError = `called function is not defined "${name}"`;
const expectedError = `Called function is not defined: "${name}"`;
const func = createSharedFunctionStubWithCode()
.withName('unexpected-name');
const sut = new SharedFunctionCollection();

View File

@@ -1,31 +1,56 @@
import { describe, it, expect } from 'vitest';
import type { FunctionData, CodeInstruction } from '@/application/collections/';
import type { ISharedFunction } from '@/application/Parser/Script/Compiler/Function/ISharedFunction';
import { SharedFunctionsParser } from '@/application/Parser/Script/Compiler/Function/SharedFunctionsParser';
import { SharedFunctionsParser, type FunctionParameterFactory } from '@/application/Parser/Script/Compiler/Function/SharedFunctionsParser';
import { createFunctionDataWithCode, createFunctionDataWithoutCallOrCode } from '@tests/unit/shared/Stubs/FunctionDataStub';
import { ParameterDefinitionDataStub } from '@tests/unit/shared/Stubs/ParameterDefinitionDataStub';
import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter';
import { FunctionCallDataStub } from '@tests/unit/shared/Stubs/FunctionCallDataStub';
import { itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests';
import type { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub';
import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests';
import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests';
import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub';
import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator';
import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines';
import { NoDuplicatedLines } from '@/application/Parser/Script/Validation/Rules/NoDuplicatedLines';
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { FunctionParameterStub } from '@tests/unit/shared/Stubs/FunctionParameterStub';
import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub';
import { itThrowsContextualError } from '@tests/unit/application/Parser/ContextualErrorTester';
import type { FunctionParameterCollectionFactory } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory';
import { FunctionParameterCollectionStub } from '@tests/unit/shared/Stubs/FunctionParameterCollectionStub';
import { expectCallsFunctionBody, expectCodeFunctionBody } from './ExpectFunctionBodyType';
describe('SharedFunctionsParser', () => {
describe('instance', () => {
itIsSingleton({
itIsSingletonFactory({
getter: () => SharedFunctionsParser.instance,
expectedType: SharedFunctionsParser,
});
});
describe('parseFunctions', () => {
describe('validates functions', () => {
it('throws when functions have no names', () => {
// arrange
const invalidFunctions = [
createFunctionDataWithCode()
.withCode('test function 1')
.withName(' '), // Whitespace,
createFunctionDataWithCode()
.withCode('test function 2')
.withName(undefined as unknown as string), // Undefined
createFunctionDataWithCode()
.withCode('test function 3')
.withName(''), // Empty
];
const expectedError = `Some function(s) have no names:\n${invalidFunctions.map((f) => JSON.stringify(f)).join('\n')}`;
// act
const act = () => new ParseFunctionsCallerWithDefaults()
.withFunctions(invalidFunctions)
.parseFunctions();
// assert
expect(act).to.throw(expectedError);
});
it('throws when functions have same names', () => {
// arrange
const name = 'same-func-name';
@@ -106,7 +131,7 @@ describe('SharedFunctionsParser', () => {
});
});
describe('throws when parameters type is not as expected', () => {
const testCases = [
const testScenarios = [
{
state: 'when not an array',
invalidType: 5,
@@ -116,7 +141,7 @@ describe('SharedFunctionsParser', () => {
invalidType: ['a', { a: 'b' }],
},
];
for (const testCase of testCases) {
for (const testCase of testScenarios) {
it(testCase.state, () => {
// arrange
const func = createFunctionDataWithCode()
@@ -149,25 +174,33 @@ describe('SharedFunctionsParser', () => {
rules: expectedRules,
});
});
it('rethrows including function name when FunctionParameter throws', () => {
// arrange
const invalidParameterName = 'invalid function p@r4meter name';
const functionName = 'functionName';
const message = collectExceptionMessage(
() => new FunctionParameter(invalidParameterName, false),
);
const expectedError = `"${functionName}": ${message}`;
const functionData = createFunctionDataWithCode()
.withName(functionName)
.withParameters(new ParameterDefinitionDataStub().withName(invalidParameterName));
// act
const act = () => new ParseFunctionsCallerWithDefaults()
.withFunctions([functionData])
.parseFunctions();
// assert
expect(act).to.throw(expectedError);
describe('parameter creation', () => {
describe('rethrows including function name when creating parameter throws', () => {
// arrange
const invalidParameterName = 'invalid-function-parameter-name';
const functionName = 'functionName';
const expectedErrorMessage = `Failed to create parameter: ${invalidParameterName} for function "${functionName}"`;
const expectedInnerError = new Error('injected error');
const parameterFactory: FunctionParameterFactory = () => {
throw expectedInnerError;
};
const functionData = createFunctionDataWithCode()
.withName(functionName)
.withParameters(new ParameterDefinitionDataStub().withName(invalidParameterName));
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
new ParseFunctionsCallerWithDefaults()
.withFunctions([functionData])
.withFunctionParameterFactory(parameterFactory)
.withErrorWrapper(wrapError)
.parseFunctions();
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
});
});
describe('given empty functions, returns empty collection', () => {
@@ -261,6 +294,18 @@ class ParseFunctionsCallerWithDefaults {
private functions: readonly FunctionData[] = [createFunctionDataWithCode()];
private wrapError: ErrorWithContextWrapper = errorWithContextWrapperStub;
private parameterFactory: FunctionParameterFactory = (
name: string,
isOptional: boolean,
) => new FunctionParameterStub()
.withName(name)
.withOptional(isOptional);
private parameterCollectionFactory
: FunctionParameterCollectionFactory = () => new FunctionParameterCollectionStub();
public withSyntax(syntax: ILanguageSyntax) {
this.syntax = syntax;
return this;
@@ -276,8 +321,32 @@ class ParseFunctionsCallerWithDefaults {
return this;
}
public withErrorWrapper(wrapError: ErrorWithContextWrapper): this {
this.wrapError = wrapError;
return this;
}
public withFunctionParameterFactory(parameterFactory: FunctionParameterFactory): this {
this.parameterFactory = parameterFactory;
return this;
}
public withParameterCollectionFactory(
parameterCollectionFactory: FunctionParameterCollectionFactory,
): this {
this.parameterCollectionFactory = parameterCollectionFactory;
return this;
}
public parseFunctions() {
const sut = new SharedFunctionsParser(this.codeValidator);
const sut = new SharedFunctionsParser(
{
codeValidator: this.codeValidator,
wrapError: this.wrapError,
createParameter: this.parameterFactory,
createParameterCollection: this.parameterCollectionFactory,
},
);
return sut.parseFunctions(this.functions, this.syntax);
}
}

View File

@@ -1,9 +1,7 @@
import { describe, it, expect } from 'vitest';
import type { FunctionData } from '@/application/collections/';
import { ScriptCode } from '@/domain/ScriptCode';
import { ScriptCompiler } from '@/application/Parser/Script/Compiler/ScriptCompiler';
import type { ISharedFunctionsParser } from '@/application/Parser/Script/Compiler/Function/ISharedFunctionsParser';
import type { CompiledCode } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/CompiledCode';
import type { FunctionCallCompiler } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompiler';
import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub';
import { createFunctionDataWithCode } from '@tests/unit/shared/Stubs/FunctionDataStub';
@@ -17,8 +15,13 @@ import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICod
import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub';
import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines';
import { CompiledCodeStub } from '@tests/unit/shared/Stubs/CompiledCodeStub';
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
import { createScriptDataWithCall, createScriptDataWithCode } from '@tests/unit/shared/Stubs/ScriptDataStub';
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { errorWithContextWrapperStub } from '@tests/unit/shared/Stubs/ErrorWithContextWrapperStub';
import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub';
import type { ScriptCodeFactory } from '@/domain/ScriptCodeFactory';
import { createScriptCodeFactoryStub } from '@tests/unit/shared/Stubs/ScriptCodeFactoryStub';
import { itThrowsContextualError } from '../../ContextualErrorTester';
describe('ScriptCompiler', () => {
describe('canCompile', () => {
@@ -58,31 +61,59 @@ describe('ScriptCompiler', () => {
// assert
expect(act).to.throw(expectedError);
});
it('returns code as expected', () => {
// arrange
const expected: CompiledCode = {
code: 'expected-code',
revertCode: 'expected-revert-code',
};
const call = new FunctionCallDataStub();
const script = createScriptDataWithCall(call);
const functions = [createFunctionDataWithCode().withName('existing-func')];
const compiledFunctions = new SharedFunctionCollectionStub();
const functionParserMock = new SharedFunctionsParserStub();
functionParserMock.setup(functions, compiledFunctions);
const callCompilerMock = new FunctionCallCompilerStub();
callCompilerMock.setup(parseFunctionCalls(call), compiledFunctions, expected);
const sut = new ScriptCompilerBuilder()
.withFunctions(...functions)
.withSharedFunctionsParser(functionParserMock)
.withFunctionCallCompiler(callCompilerMock)
.build();
// act
const code = sut.compile(script);
// assert
expect(code.execute).to.equal(expected.code);
expect(code.revert).to.equal(expected.revertCode);
describe('code construction', () => {
it('returns code from the factory', () => {
// arrange
const expectedCode = new ScriptCodeStub();
const scriptCodeFactory = () => expectedCode;
const sut = new ScriptCompilerBuilder()
.withSomeFunctions()
.withScriptCodeFactory(scriptCodeFactory)
.build();
// act
const actualCode = sut.compile(createScriptDataWithCall());
// assert
expect(actualCode).to.equal(expectedCode);
});
it('creates code correctly', () => {
// arrange
const expectedCode = 'expected-code';
const expectedRevertCode = 'expected-revert-code';
let actualCode: string | undefined;
let actualRevertCode: string | undefined;
const scriptCodeFactory = (code: string, revertCode: string) => {
actualCode = code;
actualRevertCode = revertCode;
return new ScriptCodeStub();
};
const call = new FunctionCallDataStub();
const script = createScriptDataWithCall(call);
const functions = [createFunctionDataWithCode().withName('existing-func')];
const compiledFunctions = new SharedFunctionCollectionStub();
const functionParserMock = new SharedFunctionsParserStub();
functionParserMock.setup(functions, compiledFunctions);
const callCompilerMock = new FunctionCallCompilerStub();
callCompilerMock.setup(
parseFunctionCalls(call),
compiledFunctions,
new CompiledCodeStub()
.withCode(expectedCode)
.withRevertCode(expectedRevertCode),
);
const sut = new ScriptCompilerBuilder()
.withFunctions(...functions)
.withSharedFunctionsParser(functionParserMock)
.withFunctionCallCompiler(callCompilerMock)
.withScriptCodeFactory(scriptCodeFactory)
.build();
// act
sut.compile(script);
// assert
expect(actualCode).to.equal(expectedCode);
expect(actualRevertCode).to.equal(expectedRevertCode);
});
});
describe('parses functions as expected', () => {
it('parses functions with expected syntax', () => {
// arrange
@@ -116,49 +147,57 @@ describe('ScriptCompiler', () => {
expect(parser.callHistory[0].functions).to.deep.equal(expectedFunctions);
});
});
it('rethrows error with script name', () => {
describe('rethrows error with script name', () => {
// arrange
const scriptName = 'scriptName';
const innerError = 'innerError';
const expectedError = `Script "${scriptName}" ${innerError}`;
const expectedErrorMessage = `Failed to compile script: ${scriptName}`;
const expectedInnerError = new Error();
const callCompiler: FunctionCallCompiler = {
compileFunctionCalls: () => { throw new Error(innerError); },
compileFunctionCalls: () => { throw expectedInnerError; },
};
const scriptData = createScriptDataWithCall()
.withName(scriptName);
const sut = new ScriptCompilerBuilder()
const builder = new ScriptCompilerBuilder()
.withSomeFunctions()
.withFunctionCallCompiler(callCompiler)
.build();
// act
const act = () => sut.compile(scriptData);
// assert
expect(act).to.throw(expectedError);
.withFunctionCallCompiler(callCompiler);
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
builder
.withErrorWrapper(wrapError)
.build()
.compile(scriptData);
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
it('rethrows error from ScriptCode with script name', () => {
describe('rethrows error from script code factory with script name', () => {
// arrange
const scriptName = 'scriptName';
const syntax = new LanguageSyntaxStub();
const invalidCode = new CompiledCodeStub()
.withCode('' /* invalid code (empty string) */);
const realExceptionMessage = collectExceptionMessage(
() => new ScriptCode(invalidCode.code, invalidCode.revertCode),
);
const expectedError = `Script "${scriptName}" ${realExceptionMessage}`;
const callCompiler: FunctionCallCompiler = {
compileFunctionCalls: () => invalidCode,
const expectedErrorMessage = `Failed to compile script: ${scriptName}`;
const expectedInnerError = new Error();
const scriptCodeFactory: ScriptCodeFactory = () => {
throw expectedInnerError;
};
const scriptData = createScriptDataWithCall()
.withName(scriptName);
const sut = new ScriptCompilerBuilder()
const builder = new ScriptCompilerBuilder()
.withSomeFunctions()
.withFunctionCallCompiler(callCompiler)
.withSyntax(syntax)
.build();
// act
const act = () => sut.compile(scriptData);
// assert
expect(act).to.throw(expectedError);
.withScriptCodeFactory(scriptCodeFactory);
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
builder
.withErrorWrapper(wrapError)
.build()
.compile(scriptData);
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
it('validates compiled code as expected', () => {
// arrange
@@ -166,17 +205,27 @@ describe('ScriptCompiler', () => {
NoEmptyLines,
// Allow duplicated lines to enable calling same function multiple times
];
const expectedExecuteCode = 'execute code to be validated';
const expectedRevertCode = 'revert code to be validated';
const scriptData = createScriptDataWithCall();
const validator = new CodeValidatorStub();
const sut = new ScriptCompilerBuilder()
.withSomeFunctions()
.withCodeValidator(validator)
.withFunctionCallCompiler(
new FunctionCallCompilerStub()
.withDefaultCompiledCode(
new CompiledCodeStub()
.withCode(expectedExecuteCode)
.withRevertCode(expectedRevertCode),
),
)
.build();
// act
const compilationResult = sut.compile(scriptData);
sut.compile(scriptData);
// assert
validator.assertHistory({
validatedCodes: [compilationResult.execute, compilationResult.revert],
validatedCodes: [expectedExecuteCode, expectedRevertCode],
rules: expectedRules,
});
});
@@ -200,6 +249,12 @@ class ScriptCompilerBuilder {
private codeValidator: ICodeValidator = new CodeValidatorStub();
private wrapError: ErrorWithContextWrapper = errorWithContextWrapperStub;
private scriptCodeFactory: ScriptCodeFactory = createScriptCodeFactoryStub({
defaultCodePrefix: ScriptCompilerBuilder.name,
});
public withFunctions(...functions: FunctionData[]): this {
this.functions = functions;
return this;
@@ -244,6 +299,16 @@ class ScriptCompilerBuilder {
return this;
}
public withErrorWrapper(wrapError: ErrorWithContextWrapper): this {
this.wrapError = wrapError;
return this;
}
public withScriptCodeFactory(scriptCodeFactory: ScriptCodeFactory): this {
this.scriptCodeFactory = scriptCodeFactory;
return this;
}
public build(): ScriptCompiler {
if (!this.functions) {
throw new Error('Function behavior not defined');
@@ -254,6 +319,8 @@ class ScriptCompilerBuilder {
this.sharedFunctionsParser,
this.callCompiler,
this.codeValidator,
this.wrapError,
this.scriptCodeFactory,
);
}
}

View File

@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import type { ScriptData } from '@/application/collections/';
import { parseScript, type ScriptFactoryType } from '@/application/Parser/Script/ScriptParser';
import { parseDocs } from '@/application/Parser/DocumentationParser';
import { parseScript, type ScriptFactory } from '@/application/Parser/Script/ScriptParser';
import { type DocsParser } from '@/application/Parser/DocumentationParser';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
import type { ICategoryCollectionParseContext } from '@/application/Parser/Script/ICategoryCollectionParseContext';
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
@@ -11,54 +11,88 @@ import { EnumParserStub } from '@tests/unit/shared/Stubs/EnumParserStub';
import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub';
import { CategoryCollectionParseContextStub } from '@tests/unit/shared/Stubs/CategoryCollectionParseContextStub';
import { LanguageSyntaxStub } from '@tests/unit/shared/Stubs/LanguageSyntaxStub';
import { NodeType } from '@/application/Parser/NodeValidation/NodeType';
import { expectThrowsNodeError, type ITestScenario, NodeValidationTestRunner } from '@tests/unit/application/Parser/NodeValidation/NodeValidatorTestRunner';
import { Script } from '@/domain/Script';
import type { IEnumParser } from '@/application/Common/Enum';
import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines';
import { NoDuplicatedLines } from '@/application/Parser/Script/Validation/Rules/NoDuplicatedLines';
import { CodeValidatorStub } from '@tests/unit/shared/Stubs/CodeValidatorStub';
import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator';
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { ErrorWrapperStub } from '@tests/unit/shared/Stubs/ErrorWrapperStub';
import type { NodeDataValidatorFactory } from '@/application/Parser/NodeValidation/NodeDataValidator';
import { NodeDataValidatorStub, createNodeDataValidatorFactoryStub } from '@tests/unit/shared/Stubs/NodeDataValidatorStub';
import { NodeDataType } from '@/application/Parser/NodeValidation/NodeDataType';
import type { ScriptNodeErrorContext } from '@/application/Parser/NodeValidation/NodeDataErrorContext';
import type { ScriptCodeFactory } from '@/domain/ScriptCodeFactory';
import { createScriptCodeFactoryStub } from '@tests/unit/shared/Stubs/ScriptCodeFactoryStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { createScriptFactorySpy } from '@tests/unit/shared/Stubs/ScriptFactoryStub';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { itThrowsContextualError } from '../ContextualErrorTester';
import { itAsserts, itValidatesDefinedData, itValidatesName } from '../NodeDataValidationTester';
import { generateDataValidationTestScenarios } from '../DataValidationTestScenarioGenerator';
describe('ScriptParser', () => {
describe('parseScript', () => {
it('parses name as expected', () => {
it('parses name correctly', () => {
// arrange
const expected = 'test-expected-name';
const script = createScriptDataWithCode()
const scriptData = createScriptDataWithCode()
.withName(expected);
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
// act
const actual = new TestBuilder()
.withData(script)
const actualScript = new TestContext()
.withData(scriptData)
.withScriptFactory(scriptFactorySpy)
.parseScript();
// assert
expect(actual.name).to.equal(expected);
const actualName = getInitParameters(actualScript)?.name;
expect(actualName).to.equal(expected);
});
it('parses docs as expected', () => {
it('parses docs correctly', () => {
// arrange
const docs = ['https://expected-doc1.com', 'https://expected-doc2.com'];
const script = createScriptDataWithCode()
.withDocs(docs);
const expected = parseDocs(script);
const expectedDocs = ['https://expected-doc1.com', 'https://expected-doc2.com'];
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
const scriptData = createScriptDataWithCode()
.withDocs(expectedDocs);
const docsParser: DocsParser = (data) => data.docs as typeof expectedDocs;
// act
const actual = new TestBuilder()
.withData(script)
const actualScript = new TestContext()
.withData(scriptData)
.withScriptFactory(scriptFactorySpy)
.withDocsParser(docsParser)
.parseScript();
// assert
expect(actual.docs).to.deep.equal(expected);
const actualDocs = getInitParameters(actualScript)?.docs;
expect(actualDocs).to.deep.equal(expectedDocs);
});
it('gets script from the factory', () => {
// arrange
const expectedScript = new ScriptStub('expected-script');
const scriptFactory: ScriptFactory = () => expectedScript;
// act
const actualScript = new TestContext()
.withScriptFactory(scriptFactory)
.parseScript();
// assert
expect(actualScript).to.equal(expectedScript);
});
describe('level', () => {
describe('accepts absent level', () => {
describe('generated `undefined` level if given absent value', () => {
itEachAbsentStringValue((absentValue) => {
// arrange
const script = createScriptDataWithCode()
const expectedLevel = undefined;
const scriptData = createScriptDataWithCode()
.withRecommend(absentValue);
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
// act
const actual = new TestBuilder()
.withData(script)
const actualScript = new TestContext()
.withData(scriptData)
.withScriptFactory(scriptFactorySpy)
.parseScript();
// assert
expect(actual.level).to.equal(undefined);
const actualLevel = getInitParameters(actualScript)?.level;
expect(actualLevel).to.equal(expectedLevel);
}, { excludeNull: true });
});
it('parses level as expected', () => {
@@ -66,63 +100,94 @@ describe('ScriptParser', () => {
const expectedLevel = RecommendationLevel.Standard;
const expectedName = 'level';
const levelText = 'standard';
const script = createScriptDataWithCode()
const scriptData = createScriptDataWithCode()
.withRecommend(levelText);
const parserMock = new EnumParserStub<RecommendationLevel>()
.setup(expectedName, levelText, expectedLevel);
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
// act
const actual = new TestBuilder()
.withData(script)
const actualScript = new TestContext()
.withData(scriptData)
.withParser(parserMock)
.withScriptFactory(scriptFactorySpy)
.parseScript();
// assert
expect(actual.level).to.equal(expectedLevel);
const actualLevel = getInitParameters(actualScript)?.level;
expect(actualLevel).to.equal(expectedLevel);
});
});
describe('code', () => {
it('parses "execute" as expected', () => {
it('creates from script code factory', () => {
// arrange
const expected = 'expected-code';
const script = createScriptDataWithCode()
.withCode(expected);
const expectedCode = new ScriptCodeStub();
const scriptCodeFactory: ScriptCodeFactory = () => expectedCode;
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
// act
const parsed = new TestBuilder()
.withData(script)
const actualScript = new TestContext()
.withScriptCodeFactory(scriptCodeFactory)
.withScriptFactory(scriptFactorySpy)
.parseScript();
// assert
const actual = parsed.code.execute;
expect(actual).to.equal(expected);
const actualCode = getInitParameters(actualScript)?.code;
expect(expectedCode).to.equal(actualCode);
});
it('parses "revert" as expected', () => {
// arrange
const expected = 'expected-revert-code';
const script = createScriptDataWithCode()
.withRevertCode(expected);
// act
const parsed = new TestBuilder()
.withData(script)
.parseScript();
// assert
const actual = parsed.code.revert;
expect(actual).to.equal(expected);
});
describe('compiler', () => {
it('gets code from compiler', () => {
describe('parses code correctly', () => {
it('parses "execute" as expected', () => {
// arrange
const expected = new ScriptCodeStub();
const script = createScriptDataWithCode();
const compiler = new ScriptCompilerStub()
.withCompileAbility(script, expected);
const parseContext = new CategoryCollectionParseContextStub()
.withCompiler(compiler);
const expectedCode = 'expected-code';
let actualCode: string | undefined;
const scriptCodeFactory: ScriptCodeFactory = (code) => {
actualCode = code;
return new ScriptCodeStub();
};
const scriptData = createScriptDataWithCode()
.withCode(expectedCode);
// act
const parsed = new TestBuilder()
.withData(script)
.withContext(parseContext)
new TestContext()
.withData(scriptData)
.withScriptCodeFactory(scriptCodeFactory)
.parseScript();
// assert
const actual = parsed.code;
expect(actual).to.equal(expected);
expect(actualCode).to.equal(expectedCode);
});
it('parses "revert" as expected', () => {
// arrange
const expectedRevertCode = 'expected-revert-code';
const scriptData = createScriptDataWithCode()
.withRevertCode(expectedRevertCode);
let actualRevertCode: string | undefined;
const scriptCodeFactory: ScriptCodeFactory = (_, revertCode) => {
actualRevertCode = revertCode;
return new ScriptCodeStub();
};
// act
new TestContext()
.withData(scriptData)
.withScriptCodeFactory(scriptCodeFactory)
.parseScript();
// assert
expect(actualRevertCode).to.equal(expectedRevertCode);
});
});
describe('compiler', () => {
it('compiles the code through the compiler', () => {
// arrange
const expectedCode = new ScriptCodeStub();
const script = createScriptDataWithCode();
const compiler = new ScriptCompilerStub()
.withCompileAbility(script, expectedCode);
const parseContext = new CategoryCollectionParseContextStub()
.withCompiler(compiler);
const { scriptFactorySpy, getInitParameters } = createScriptFactorySpy();
// act
const actualScript = new TestContext()
.withData(script)
.withContext(parseContext)
.withScriptFactory(scriptFactorySpy)
.parseScript();
// assert
const actualCode = getInitParameters(actualScript)?.code;
expect(actualCode).to.equal(expectedCode);
});
});
describe('syntax', () => {
@@ -135,7 +200,7 @@ describe('ScriptParser', () => {
const script = createScriptDataWithoutCallOrCodes()
.withCode(duplicatedCode);
// act
const act = () => new TestBuilder()
const act = () => new TestContext()
.withData(script)
.withContext(parseContext);
// assert
@@ -149,18 +214,26 @@ describe('ScriptParser', () => {
NoEmptyLines,
NoDuplicatedLines,
];
const expectedCode = 'expected code to be validated';
const expectedRevertCode = 'expected revert code to be validated';
const expectedCodeCalls = [
expectedCode,
expectedRevertCode,
];
const validator = new CodeValidatorStub();
const script = createScriptDataWithCode()
.withCode('expected code to be validated')
.withRevertCode('expected revert code to be validated');
const scriptCodeFactory = createScriptCodeFactoryStub({
scriptCode: new ScriptCodeStub()
.withExecute(expectedCode)
.withRevert(expectedRevertCode),
});
// act
new TestBuilder()
.withData(script)
new TestContext()
.withScriptCodeFactory(scriptCodeFactory)
.withCodeValidator(validator)
.parseScript();
// assert
validator.assertHistory({
validatedCodes: [script.code, script.revertCode],
validatedCodes: expectedCodeCalls,
rules: expectedRules,
});
});
@@ -175,7 +248,7 @@ describe('ScriptParser', () => {
const parseContext = new CategoryCollectionParseContextStub()
.withCompiler(compiler);
// act
new TestBuilder()
new TestContext()
.withData(script)
.withCodeValidator(validator)
.withContext(parseContext)
@@ -188,111 +261,250 @@ describe('ScriptParser', () => {
});
});
});
describe('invalid script data', () => {
describe('validates script data', () => {
describe('validation', () => {
describe('validates for name', () => {
// arrange
const createTest = (script: ScriptData): ITestScenario => ({
act: () => new TestBuilder()
const expectedName = 'expected script name to be validated';
const script = createScriptDataWithCall()
.withName(expectedName);
const expectedContext: ScriptNodeErrorContext = {
type: NodeDataType.Script,
selfNode: script,
};
itValidatesName((validatorFactory) => {
// act
new TestContext()
.withData(script)
.parseScript(),
expectedContext: {
type: NodeType.Script,
selfNode: script,
},
.withValidatorFactory(validatorFactory)
.parseScript();
// assert
return {
expectedNameToValidate: expectedName,
expectedErrorContext: expectedContext,
};
});
// act and assert
new NodeValidationTestRunner()
.testInvalidNodeName((invalidName) => {
return createTest(
createScriptDataWithCall().withName(invalidName),
);
})
.testMissingNodeData((node) => {
return createTest(node as ScriptData);
})
.runThrowingCase({
name: 'throws when both function call and code are defined',
scenario: createTest(
createScriptDataWithCall().withCode('code'),
),
expectedMessage: 'Both "call" and "code" are defined.',
})
.runThrowingCase({
name: 'throws when both function call and revertCode are defined',
scenario: createTest(
createScriptDataWithCall().withRevertCode('revert-code'),
),
expectedMessage: 'Both "call" and "revertCode" are defined.',
})
.runThrowingCase({
name: 'throws when neither call or revertCode are defined',
scenario: createTest(
createScriptDataWithoutCallOrCodes(),
),
expectedMessage: 'Neither "call" or "code" is defined.',
});
});
it(`rethrows exception if ${Script.name} cannot be constructed`, () => {
describe('validates for defined data', () => {
// arrange
const expectedError = 'script creation failed';
const factoryMock: ScriptFactoryType = () => { throw new Error(expectedError); };
const data = createScriptDataWithCode();
// act
const act = () => new TestBuilder()
.withData(data)
.withFactory(factoryMock)
.parseScript();
// expect
expectThrowsNodeError({
act,
expectedContext: {
type: NodeType.Script,
selfNode: data,
const expectedScript = createScriptDataWithCall();
const expectedContext: ScriptNodeErrorContext = {
type: NodeDataType.Script,
selfNode: expectedScript,
};
itValidatesDefinedData(
(validatorFactory) => {
// act
new TestContext()
.withData(expectedScript)
.withValidatorFactory(validatorFactory)
.parseScript();
// assert
return {
expectedDataToValidate: expectedScript,
expectedErrorContext: expectedContext,
};
},
}, expectedError);
);
});
describe('validates data', () => {
// arrange
const testScenarios = generateDataValidationTestScenarios<ScriptData>(
{
assertErrorMessage: 'Neither "call" or "code" is defined.',
expectFail: [{
description: 'with no call or code',
data: createScriptDataWithoutCallOrCodes(),
}],
expectPass: [
{
description: 'with call',
data: createScriptDataWithCall(),
},
{
description: 'with code',
data: createScriptDataWithCode(),
},
],
},
{
assertErrorMessage: 'Both "call" and "revertCode" are defined.',
expectFail: [{
description: 'with both call and revertCode',
data: createScriptDataWithCall()
.withRevertCode('revert-code'),
}],
expectPass: [
{
description: 'with call, without revertCode',
data: createScriptDataWithCall()
.withRevertCode(undefined),
},
{
description: 'with revertCode, without call',
data: createScriptDataWithCode()
.withRevertCode('revert code'),
},
],
},
{
assertErrorMessage: 'Both "call" and "code" are defined.',
expectFail: [{
description: 'with both call and code',
data: createScriptDataWithCall()
.withCode('code'),
}],
expectPass: [
{
description: 'with call, without code',
data: createScriptDataWithCall()
.withCode(''),
},
{
description: 'with code, without call',
data: createScriptDataWithCode()
.withCode('code'),
},
],
},
);
testScenarios.forEach(({
description, expectedPass, data: scriptData, expectedMessage,
}) => {
describe(description, () => {
itAsserts({
expectedConditionResult: expectedPass,
test: (validatorFactory) => {
const expectedContext: ScriptNodeErrorContext = {
type: NodeDataType.Script,
selfNode: scriptData,
};
// act
new TestContext()
.withData(scriptData)
.withValidatorFactory(validatorFactory)
.parseScript();
// assert
expectExists(expectedMessage);
return {
expectedErrorMessage: expectedMessage,
expectedErrorContext: expectedContext,
};
},
});
});
});
});
});
describe('rethrows exception if script factory fails', () => {
// arrange
const givenData = createScriptDataWithCode();
const expectedContextMessage = 'Failed to parse script.';
const expectedError = new Error();
const validatorFactory: NodeDataValidatorFactory = () => {
const validatorStub = new NodeDataValidatorStub();
validatorStub.createContextualErrorMessage = (message) => message;
return validatorStub;
};
// act & assert
itThrowsContextualError({
throwingAction: (wrapError) => {
const factoryMock: ScriptFactory = () => {
throw expectedError;
};
new TestContext()
.withScriptFactory(factoryMock)
.withErrorWrapper(wrapError)
.withValidatorFactory(validatorFactory)
.withData(givenData)
.parseScript();
},
expectedWrappedError: expectedError,
expectedContextMessage,
});
});
});
});
class TestBuilder {
class TestContext {
private data: ScriptData = createScriptDataWithCode();
private context: ICategoryCollectionParseContext = new CategoryCollectionParseContextStub();
private parser: IEnumParser<RecommendationLevel> = new EnumParserStub<RecommendationLevel>()
private levelParser: IEnumParser<RecommendationLevel> = new EnumParserStub<RecommendationLevel>()
.setupDefaultValue(RecommendationLevel.Standard);
private factory?: ScriptFactoryType = undefined;
private scriptFactory: ScriptFactory = createScriptFactorySpy().scriptFactorySpy;
private codeValidator: ICodeValidator = new CodeValidatorStub();
public withCodeValidator(codeValidator: ICodeValidator) {
private errorWrapper: ErrorWithContextWrapper = new ErrorWrapperStub().get();
private validatorFactory: NodeDataValidatorFactory = createNodeDataValidatorFactoryStub;
private docsParser: DocsParser = () => ['docs'];
private scriptCodeFactory: ScriptCodeFactory = createScriptCodeFactoryStub({
defaultCodePrefix: TestContext.name,
});
public withCodeValidator(codeValidator: ICodeValidator): this {
this.codeValidator = codeValidator;
return this;
}
public withData(data: ScriptData) {
public withData(data: ScriptData): this {
this.data = data;
return this;
}
public withContext(context: ICategoryCollectionParseContext) {
public withContext(context: ICategoryCollectionParseContext): this {
this.context = context;
return this;
}
public withParser(parser: IEnumParser<RecommendationLevel>) {
this.parser = parser;
public withParser(parser: IEnumParser<RecommendationLevel>): this {
this.levelParser = parser;
return this;
}
public withFactory(factory: ScriptFactoryType) {
this.factory = factory;
public withScriptFactory(scriptFactory: ScriptFactory): this {
this.scriptFactory = scriptFactory;
return this;
}
public withValidatorFactory(validatorFactory: NodeDataValidatorFactory): this {
this.validatorFactory = validatorFactory;
return this;
}
public withErrorWrapper(errorWrapper: ErrorWithContextWrapper): this {
this.errorWrapper = errorWrapper;
return this;
}
public withScriptCodeFactory(scriptCodeFactory: ScriptCodeFactory): this {
this.scriptCodeFactory = scriptCodeFactory;
return this;
}
public withDocsParser(docsParser: DocsParser): this {
this.docsParser = docsParser;
return this;
}
public parseScript(): Script {
return parseScript(this.data, this.context, this.parser, this.factory, this.codeValidator);
return parseScript(
this.data,
this.context,
{
levelParser: this.levelParser,
createScript: this.scriptFactory,
codeValidator: this.codeValidator,
wrapError: this.errorWrapper,
createValidator: this.validatorFactory,
createCode: this.scriptCodeFactory,
parseDocs: this.docsParser,
},
);
}
}

View File

@@ -2,13 +2,13 @@ import { describe, it, expect } from 'vitest';
import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValidator';
import { CodeValidationRuleStub } from '@tests/unit/shared/Stubs/CodeValidationRuleStub';
import { itEachAbsentCollectionValue, itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests';
import { itIsSingletonFactory } from '@tests/unit/shared/TestCases/SingletonFactoryTests';
import type { ICodeLine } from '@/application/Parser/Script/Validation/ICodeLine';
import type { ICodeValidationRule, IInvalidCodeLine } from '@/application/Parser/Script/Validation/ICodeValidationRule';
describe('CodeValidator', () => {
describe('instance', () => {
itIsSingleton({
itIsSingletonFactory({
getter: () => CodeValidator.instance,
expectedType: CodeValidator,
});

View File

@@ -3,50 +3,68 @@ import { Category } from '@/domain/Category';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
import type { ICategory, IScript } from '@/domain/ICategory';
describe('Category', () => {
describe('ctor', () => {
describe('throws when name is absent', () => {
describe('throws error if name is absent', () => {
itEachAbsentStringValue((absentValue) => {
// arrange
const expectedError = 'missing name';
const name = absentValue;
// act
const construct = () => new Category(5, name, [], [new CategoryStub(5)], []);
const construct = () => new CategoryBuilder()
.withName(name)
.build();
// assert
expect(construct).to.throw(expectedError);
}, { excludeNull: true, excludeUndefined: true });
});
it('throws when has no children', () => {
it('throws error if no children are present', () => {
// arrange
const expectedError = 'A category must have at least one sub-category or script';
const construct = () => new Category(5, 'category', [], [], []);
const scriptChildren: readonly IScript[] = [];
const categoryChildren: readonly ICategory[] = [];
// act
const construct = () => new CategoryBuilder()
.withSubcategories(categoryChildren)
.withScripts(scriptChildren)
.build();
// assert
expect(construct).to.throw(expectedError);
});
});
describe('getAllScriptsRecursively', () => {
it('gets child scripts', () => {
it('retrieves direct child scripts', () => {
// arrange
const expected = [new ScriptStub('1'), new ScriptStub('2')];
const sut = new Category(0, 'category', [], [], expected);
const expectedScripts = [new ScriptStub('1'), new ScriptStub('2')];
const sut = new CategoryBuilder()
.withScripts(expectedScripts)
.build();
// act
const actual = sut.getAllScriptsRecursively();
// assert
expect(actual).to.have.deep.members(expected);
expect(actual).to.have.deep.members(expectedScripts);
});
it('gets child categories', () => {
it('retrieves scripts from direct child categories', () => {
// arrange
const expectedScriptIds = ['1', '2', '3', '4'];
const categories = [
new CategoryStub(31).withScriptIds('1', '2'),
new CategoryStub(32).withScriptIds('3', '4'),
];
const sut = new Category(0, 'category', [], categories, []);
const sut = new CategoryBuilder()
.withScripts([])
.withSubcategories(categories)
.build();
// act
const actualIds = sut.getAllScriptsRecursively().map((s) => s.id);
const actualIds = sut
.getAllScriptsRecursively()
.map((s) => s.id);
// assert
expect(actualIds).to.have.deep.members(expectedScriptIds);
});
it('gets child scripts and categories', () => {
it('retrieves scripts from both direct children and child categories', () => {
// arrange
const expectedScriptIds = ['1', '2', '3', '4', '5', '6'];
const categories = [
@@ -54,13 +72,18 @@ describe('Category', () => {
new CategoryStub(32).withScriptIds('3', '4'),
];
const scripts = [new ScriptStub('5'), new ScriptStub('6')];
const sut = new Category(0, 'category', [], categories, scripts);
const sut = new CategoryBuilder()
.withSubcategories(categories)
.withScripts(scripts)
.build();
// act
const actualIds = sut.getAllScriptsRecursively().map((s) => s.id);
const actualIds = sut
.getAllScriptsRecursively()
.map((s) => s.id);
// assert
expect(actualIds).to.have.deep.members(expectedScriptIds);
});
it('gets child categories recursively', () => {
it('retrieves scripts from nested categories recursively', () => {
// arrange
const expectedScriptIds = ['1', '2', '3', '4', '5', '6'];
const categories = [
@@ -83,45 +106,111 @@ describe('Category', () => {
),
];
// assert
const sut = new Category(0, 'category', [], categories, []);
const sut = new CategoryBuilder()
.withScripts([])
.withSubcategories(categories)
.build();
// act
const actualIds = sut.getAllScriptsRecursively().map((s) => s.id);
const actualIds = sut
.getAllScriptsRecursively()
.map((s) => s.id);
// assert
expect(actualIds).to.have.deep.members(expectedScriptIds);
});
});
describe('includes', () => {
it('return false when does not include', () => {
it('returns false for scripts not included', () => {
// assert
const expectedResult = false;
const script = new ScriptStub('3');
const sut = new Category(0, 'category', [], [new CategoryStub(33).withScriptIds('1', '2')], []);
const childCategory = new CategoryStub(33)
.withScriptIds('1', '2');
const sut = new CategoryBuilder()
.withSubcategories([childCategory])
.build();
// act
const actual = sut.includes(script);
// assert
expect(actual).to.equal(false);
expect(actual).to.equal(expectedResult);
});
it('return true when includes as subscript', () => {
it('returns true for scripts directly included', () => {
// assert
const expectedResult = true;
const script = new ScriptStub('3');
const sut = new Category(0, 'category', [], [
new CategoryStub(33).withScript(script).withScriptIds('non-related'),
], []);
const childCategory = new CategoryStub(33)
.withScript(script)
.withScriptIds('non-related');
const sut = new CategoryBuilder()
.withSubcategories([childCategory])
.build();
// act
const actual = sut.includes(script);
// assert
expect(actual).to.equal(true);
expect(actual).to.equal(expectedResult);
});
it('return true when includes as nested category script', () => {
it('returns true for scripts included in nested categories', () => {
// assert
const expectedResult = true;
const script = new ScriptStub('3');
const innerCategory = new CategoryStub(22)
const childCategory = new CategoryStub(22)
.withScriptIds('non-related')
.withCategory(new CategoryStub(33).withScript(script));
const sut = new Category(11, 'category', [], [innerCategory], []);
const sut = new CategoryBuilder()
.withSubcategories([childCategory])
.build();
// act
const actual = sut.includes(script);
// assert
expect(actual).to.equal(true);
expect(actual).to.equal(expectedResult);
});
});
});
class CategoryBuilder {
private id = 3264;
private name = 'test-script';
private docs: ReadonlyArray<string> = [];
private subcategories: ReadonlyArray<ICategory> = [];
private scripts: ReadonlyArray<IScript> = [
new ScriptStub(`[${CategoryBuilder.name}] script`),
];
public withId(id: number): this {
this.id = id;
return this;
}
public withName(name: string): this {
this.name = name;
return this;
}
public withDocs(docs: ReadonlyArray<string>): this {
this.docs = docs;
return this;
}
public withScripts(scripts: ReadonlyArray<IScript>): this {
this.scripts = scripts;
return this;
}
public withSubcategories(subcategories: ReadonlyArray<ICategory>): this {
this.subcategories = subcategories;
return this;
}
public build(): Category {
return new Category({
id: this.id,
name: this.name,
docs: this.docs,
subcategories: this.subcategories,
scripts: this.scripts,
});
}
}

View File

@@ -8,7 +8,7 @@ import { ScriptCodeStub } from '@tests/unit/shared/Stubs/ScriptCodeStub';
describe('Script', () => {
describe('ctor', () => {
describe('scriptCode', () => {
it('sets as expected', () => {
it('assigns code correctly', () => {
// arrange
const expected = new ScriptCodeStub();
const sut = new ScriptBuilder()
@@ -43,7 +43,7 @@ describe('Script', () => {
});
});
describe('level', () => {
it('cannot construct with invalid wrong value', () => {
it('throws when constructed with invalid level', () => {
// arrange
const invalidValue: RecommendationLevel = 55 as never;
const expectedError = 'invalid level';
@@ -54,7 +54,7 @@ describe('Script', () => {
// assert
expect(construct).to.throw(expectedError);
});
it('sets undefined as expected', () => {
it('handles undefined level correctly', () => {
// arrange
const expected = undefined;
// act
@@ -64,7 +64,7 @@ describe('Script', () => {
// assert
expect(sut.level).to.equal(expected);
});
it('sets as expected', () => {
it('correctly assigns valid recommendation levels', () => {
// arrange
for (const expected of getEnumValues(RecommendationLevel)) {
// act
@@ -78,7 +78,7 @@ describe('Script', () => {
});
});
describe('docs', () => {
it('sets as expected', () => {
it('correctly assigns docs', () => {
// arrange
const expected = ['doc1', 'doc2'];
// act
@@ -130,11 +130,11 @@ class ScriptBuilder {
}
public build(): Script {
return new Script(
this.name,
this.code,
this.docs,
this.level,
);
return new Script({
name: this.name,
code: this.code,
docs: this.docs,
level: this.level,
});
}
}

View File

@@ -0,0 +1,52 @@
import { createScriptCode } from '@/domain/ScriptCodeFactory';
describe('ScriptCodeFactory', () => {
describe('createScriptCode', () => {
it('generates script code with given `code`', () => {
// arrange
const expectedCode = 'expected code';
const context = new TestContext()
.withCode(expectedCode);
// act
const code = context.createScriptCode();
// assert
const actualCode = code.execute;
expect(actualCode).to.equal(expectedCode);
});
it('generates script code with given `revertCode`', () => {
// arrange
const expectedRevertCode = 'expected revert code';
const context = new TestContext()
.withRevertCode(expectedRevertCode);
// act
const code = context.createScriptCode();
// assert
const actualRevertCode = code.revert;
expect(actualRevertCode).to.equal(expectedRevertCode);
});
});
});
class TestContext {
private code = `[${TestContext}] code`;
private revertCode = `[${TestContext}] revertCode`;
public withCode(code: string): this {
this.code = code;
return this;
}
public withRevertCode(revertCode: string): this {
this.revertCode = revertCode;
return this;
}
public createScriptCode(): ReturnType<typeof createScriptCode> {
return createScriptCode(
this.code,
this.revertCode,
);
}
}

Some files were not shown because too many files have changed in this diff Show More