Compare commits
4 Commits
anims
...
dead-urls-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6720a46d2e | ||
|
|
287b8e61a0 | ||
|
|
e7218850ba | ||
|
|
adc2089887 |
8
.github/workflows/checks.external-urls.yaml
vendored
8
.github/workflows/checks.external-urls.yaml
vendored
@@ -1,11 +1,9 @@
|
|||||||
name: checks.external-urls
|
name: checks.external-urls
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 0 * * 0' # at 00:00 on every Sunday
|
- cron: '0 0 * * 0' # at 00:00 on every Sunday
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- tests/checks/external-urls/**
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
run-check:
|
run-check:
|
||||||
@@ -23,3 +21,7 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Test
|
name: Test
|
||||||
run: npm run check:external-urls
|
run: npm run check:external-urls
|
||||||
|
env:
|
||||||
|
RANDOMIZED_URL_CHECK_LIMIT: "${{ github.event_name == 'push' && '10' || '' }}"
|
||||||
|
# - Scheduled checks has no limits, ensuring thorough testing.
|
||||||
|
# - For push events, triggered by code changes, the amount of URLs are limited to provide quick feedback.
|
||||||
|
|||||||
689
package-lock.json
generated
689
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -82,7 +82,7 @@
|
|||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"vite": "^5.1.6",
|
"vite": "^5.1.6",
|
||||||
"vitest": "^0.34.6",
|
"vitest": "^1.3.1",
|
||||||
"vue-tsc": "^1.8.19",
|
"vue-tsc": "^1.8.19",
|
||||||
"yaml-lint": "^1.7.0"
|
"yaml-lint": "^1.7.0"
|
||||||
},
|
},
|
||||||
|
|||||||
12
src/application/Common/Shuffle.ts
Normal file
12
src/application/Common/Shuffle.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
Shuffle an array of strings, returning a new array with elements in random order.
|
||||||
|
Uses the Fisher-Yates (or Durstenfeld) algorithm.
|
||||||
|
*/
|
||||||
|
export function shuffle<T>(array: readonly T[]): T[] {
|
||||||
|
const shuffledArray = [...array];
|
||||||
|
for (let i = array.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[shuffledArray[i], shuffledArray[j]] = [shuffledArray[j], shuffledArray[i]];
|
||||||
|
}
|
||||||
|
return shuffledArray;
|
||||||
|
}
|
||||||
@@ -1174,7 +1174,7 @@ actions:
|
|||||||
parameters:
|
parameters:
|
||||||
fileGlob: '%SYSTEMROOT%\Logs\DISM\DISM.log'
|
fileGlob: '%SYSTEMROOT%\Logs\DISM\DISM.log'
|
||||||
-
|
-
|
||||||
name: Clear Windows update files # Marked: stop-service-do-stuff-restart-service
|
name: Clear Windows update files
|
||||||
docs: |-
|
docs: |-
|
||||||
This script clears the contents of the `%SYSTEMROOT%\SoftwareDistribution\` directory.
|
This script clears the contents of the `%SYSTEMROOT%\SoftwareDistribution\` directory.
|
||||||
This action is sometimes called *resetting the Windows Update Agent* or *resetting Windows Update components* by Microsoft [1].
|
This action is sometimes called *resetting the Windows Update Agent* or *resetting Windows Update components* by Microsoft [1].
|
||||||
@@ -1203,18 +1203,22 @@ actions:
|
|||||||
[8]: https://web.archive.org/web/20231027190213/https://support.microsoft.com/en-us/windows/troubleshoot-problems-updating-windows-188c2b0f-10a7-d72f-65b8-32d177eb136c#WindowsVersion=Windows_11 "Troubleshoot problems updating Windows - Microsoft Support | support.microsoft.com"
|
[8]: https://web.archive.org/web/20231027190213/https://support.microsoft.com/en-us/windows/troubleshoot-problems-updating-windows-188c2b0f-10a7-d72f-65b8-32d177eb136c#WindowsVersion=Windows_11 "Troubleshoot problems updating Windows - Microsoft Support | support.microsoft.com"
|
||||||
[9]: https://web.archive.org/web/20231027190503/https://learn.microsoft.com/en-us/troubleshoot/mem/configmgr/update-management/troubleshoot-software-update-scan-failures "Troubleshoot software update scan failures - Configuration Manager | Microsoft Learn | learn.microsoft.com"
|
[9]: https://web.archive.org/web/20231027190503/https://learn.microsoft.com/en-us/troubleshoot/mem/configmgr/update-management/troubleshoot-software-update-scan-failures "Troubleshoot software update scan failures - Configuration Manager | Microsoft Learn | learn.microsoft.com"
|
||||||
[10]: https://web.archive.org/web/20231029172022/https://support.microsoft.com/en-us/topic/you-receive-an-administrators-only-error-message-in-windows-xp-when-you-try-to-visit-the-windows-update-web-site-or-the-microsoft-update-web-site-d2c732b6-21e0-a2ce-8d18-303ed71736c9 'You receive an "Administrators only" error message in Windows XP when you try to visit the Windows Update Web site or the Microsoft Update Web site - Microsoft Support | support.microsoft.com'
|
[10]: https://web.archive.org/web/20231029172022/https://support.microsoft.com/en-us/topic/you-receive-an-administrators-only-error-message-in-windows-xp-when-you-try-to-visit-the-windows-update-web-site-or-the-microsoft-update-web-site-d2c732b6-21e0-a2ce-8d18-303ed71736c9 'You receive an "Administrators only" error message in Windows XP when you try to visit the Windows Update Web site or the Microsoft Update Web site - Microsoft Support | support.microsoft.com'
|
||||||
code: |- # `sc queryex` output is the same in every OS language
|
call:
|
||||||
setlocal EnableDelayedExpansion
|
-
|
||||||
SET /A wuau_service_running=0
|
function: StopService
|
||||||
SC queryex "wuauserv"|Find "STATE"|Find /v "RUNNING">Nul||(
|
parameters:
|
||||||
SET /A wuau_service_running=1
|
serviceName: wuauserv
|
||||||
net stop wuauserv
|
waitUntilStopped: true
|
||||||
)
|
serviceRestartStateFile: '%APPDATA%\privacy.sexy-wuauserv' # Marked: refactor-with-variables (app dir should be unified, not using %TEMP% as it can be cleaned during operation)
|
||||||
del /q /s /f "%SYSTEMROOT%\SoftwareDistribution\*"
|
-
|
||||||
IF !wuau_service_running! == 1 (
|
function: ClearDirectoryContents
|
||||||
net start wuauserv
|
parameters:
|
||||||
)
|
directoryGlob: '%SYSTEMROOT%\SoftwareDistribution'
|
||||||
endlocal
|
-
|
||||||
|
function: StartService
|
||||||
|
parameters:
|
||||||
|
serviceName: wuauserv
|
||||||
|
serviceRestartStateFile: '%APPDATA%\privacy.sexy-wuauserv' # Marked: refactor-with-variables (app dir should be unified, not using %TEMP% as it can be cleaned during operation)
|
||||||
-
|
-
|
||||||
name: Clear Common Language Runtime system logs
|
name: Clear Common Language Runtime system logs
|
||||||
recommend: standard
|
recommend: standard
|
||||||
@@ -1251,7 +1255,7 @@ actions:
|
|||||||
parameters:
|
parameters:
|
||||||
directoryGlob: '%SYSTEMROOT%\System32\LogFiles\setupcln'
|
directoryGlob: '%SYSTEMROOT%\System32\LogFiles\setupcln'
|
||||||
-
|
-
|
||||||
name: Clear diagnostics tracking logs # Marked: stop-service-do-stuff-restart-service ("DiagTrack")
|
name: Clear diagnostics tracking logs
|
||||||
recommend: standard
|
recommend: standard
|
||||||
docs: |-
|
docs: |-
|
||||||
This script deletes primary telemetry files in Windows.
|
This script deletes primary telemetry files in Windows.
|
||||||
@@ -1286,6 +1290,12 @@ actions:
|
|||||||
[6]: https://web.archive.org/web/20231027164510/https://learn.microsoft.com/en-us/windows/win32/etw/configuring-and-starting-an-autologger-session "Configuring and Starting an AutoLogger Session - Win32 apps | Microsoft Learn | learn.microsoft.com"
|
[6]: https://web.archive.org/web/20231027164510/https://learn.microsoft.com/en-us/windows/win32/etw/configuring-and-starting-an-autologger-session "Configuring and Starting an AutoLogger Session - Win32 apps | Microsoft Learn | learn.microsoft.com"
|
||||||
[7]: https://web.archive.org/web/20240217185108/https://learn.microsoft.com/en-us/windows/privacy/configure-windows-diagnostic-data-in-your-organization "Configure Windows diagnostic data in your organization (Windows 10 and Windows 11) - Windows Privacy | Microsoft Learn | learn.microsoft.com"
|
[7]: https://web.archive.org/web/20240217185108/https://learn.microsoft.com/en-us/windows/privacy/configure-windows-diagnostic-data-in-your-organization "Configure Windows diagnostic data in your organization (Windows 10 and Windows 11) - Windows Privacy | Microsoft Learn | learn.microsoft.com"
|
||||||
call:
|
call:
|
||||||
|
-
|
||||||
|
function: StopService
|
||||||
|
parameters:
|
||||||
|
serviceName: DiagTrack
|
||||||
|
waitUntilStopped: true
|
||||||
|
serviceRestartStateFile: '%APPDATA%\privacy.sexy-DiagTrack' # Marked: refactor-with-variables (app dir should be unified, not using %TEMP% as it can be cleaned during operation)
|
||||||
-
|
-
|
||||||
function: DeleteFiles
|
function: DeleteFiles
|
||||||
parameters:
|
parameters:
|
||||||
@@ -1296,6 +1306,11 @@ actions:
|
|||||||
parameters:
|
parameters:
|
||||||
fileGlob: '%PROGRAMDATA%\Microsoft\Diagnosis\ETLLogs\ShutdownLogger\AutoLogger-Diagtrack-Listener.etl'
|
fileGlob: '%PROGRAMDATA%\Microsoft\Diagnosis\ETLLogs\ShutdownLogger\AutoLogger-Diagtrack-Listener.etl'
|
||||||
grantPermissions: true
|
grantPermissions: true
|
||||||
|
-
|
||||||
|
function: StartService
|
||||||
|
parameters:
|
||||||
|
serviceName: DiagTrack
|
||||||
|
serviceRestartStateFile: '%APPDATA%\privacy.sexy-DiagTrack' # Marked: refactor-with-variables (app dir should be unified, not using %TEMP% as it can be cleaned during operation)
|
||||||
-
|
-
|
||||||
name: Clear event logs in Event Viewer application
|
name: Clear event logs in Event Viewer application
|
||||||
docs: https://serverfault.com/questions/407838/do-windows-events-from-the-windows-event-log-have-sensitive-information
|
docs: https://serverfault.com/questions/407838/do-windows-events-from-the-windows-event-log-have-sensitive-information
|
||||||
@@ -1452,7 +1467,7 @@ actions:
|
|||||||
recommend: standard
|
recommend: standard
|
||||||
code: dism /online /Remove-DefaultAppAssociations
|
code: dism /online /Remove-DefaultAppAssociations
|
||||||
-
|
-
|
||||||
name: Clear System Resource Usage Monitor (SRUM) data # Marked: stop-service-do-stuff-restart-service
|
name: Clear System Resource Usage Monitor (SRUM) data
|
||||||
recommend: standard
|
recommend: standard
|
||||||
docs: |-
|
docs: |-
|
||||||
This script deletes the Windows System Resource Usage Monitor (SRUM) database file.
|
This script deletes the Windows System Resource Usage Monitor (SRUM) database file.
|
||||||
@@ -1472,47 +1487,25 @@ actions:
|
|||||||
[5]: https://web.archive.org/web/20231008135321/https://devblogs.microsoft.com/sustainable-software/measuring-your-application-power-and-carbon-impact-part-1/ "Measuring Your Application Power and Carbon Impact (Part 1) - Sustainable Software | devblogs.microsoft.com"
|
[5]: https://web.archive.org/web/20231008135321/https://devblogs.microsoft.com/sustainable-software/measuring-your-application-power-and-carbon-impact-part-1/ "Measuring Your Application Power and Carbon Impact (Part 1) - Sustainable Software | devblogs.microsoft.com"
|
||||||
[6]: https://web.archive.org/web/20231008135333/https://www.sciencedirect.com/science/article/abs/pii/S1742287615000031 "Forensic implications of System Resource Usage Monitor (SRUM) data in Windows 8 | Yogesh Khatri | sciencedirect.com"
|
[6]: https://web.archive.org/web/20231008135333/https://www.sciencedirect.com/science/article/abs/pii/S1742287615000031 "Forensic implications of System Resource Usage Monitor (SRUM) data in Windows 8 | Yogesh Khatri | sciencedirect.com"
|
||||||
call:
|
call:
|
||||||
function: RunPowerShell
|
-
|
||||||
parameters:
|
|
||||||
# If the service is not stopped, following error is thrown:
|
# If the service is not stopped, following error is thrown:
|
||||||
# Failed to delete SRUM database file at: "C:\Windows\System32\sru\SRUDB.dat". Error Details: The process cannot access
|
# Failed to delete SRUM database file at: "C:\Windows\System32\sru\SRUDB.dat". Error Details: The process cannot access
|
||||||
# the file 'C:\Windows\System32\sru\SRUDB.dat' because it is being used by another process.
|
# the file 'C:\Windows\System32\sru\SRUDB.dat' because it is being used by another proces
|
||||||
code: |-
|
function: StopService
|
||||||
$srumDatabaseFilePath = "$env:WINDIR\System32\sru\SRUDB.dat"
|
parameters:
|
||||||
if (!(Test-Path -Path $srumDatabaseFilePath)) {
|
serviceName: DPS
|
||||||
Write-Output "Skipping, SRUM database file not found at `"$srumDatabaseFilePath`". No actions are required."
|
waitUntilStopped: true
|
||||||
exit 0
|
serviceRestartStateFile: '%APPDATA%\privacy.sexy-DPS' # Marked: refactor-with-variables (app dir should be unified, not using %TEMP% as it can be cleaned during operation)
|
||||||
}
|
-
|
||||||
$dps = Get-Service -Name 'DPS' -ErrorAction Ignore
|
function: DeleteFiles
|
||||||
$isDpsInitiallyRunning = $false
|
parameters:
|
||||||
if ($dps) {
|
fileGlob: '%WINDIR%\System32\sru\SRUDB.dat'
|
||||||
$isDpsInitiallyRunning = $dps.Status -eq [System.ServiceProcess.ServiceControllerStatus]::Running
|
grantPermissions: true
|
||||||
if ($isDpsInitiallyRunning) {
|
-
|
||||||
Write-Output "Stopping the Diagnostic Policy Service (DPS) to delete the SRUM database file."
|
function: StartService
|
||||||
$dps | Stop-Service -Force
|
parameters:
|
||||||
$dps.WaitForStatus([System.ServiceProcess.ServiceControllerStatus]::Stopped)
|
serviceName: DPS
|
||||||
Write-Output "Successfully stopped Diagnostic Policy Service (DPS)."
|
serviceRestartStateFile: '%APPDATA%\privacy.sexy-DPS' # Marked: refactor-with-variables (app dir should be unified, not using %TEMP% as it can be cleaned during operation)
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Write-Output "Diagnostic Policy Service (DPS) not found. Proceeding without stopping the service."
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
Remove-Item -Path $srumDatabaseFilePath -Force -ErrorAction Stop
|
|
||||||
Write-Output "Successfully deleted the SRUM database file at `"$srumDatabaseFilePath`"."
|
|
||||||
} catch {
|
|
||||||
throw "Failed to delete SRUM database file at: `"$srumDatabaseFilePath`". Error Details: $($_.Exception.Message)"
|
|
||||||
} finally {
|
|
||||||
if ($isDpsInitiallyRunning) {
|
|
||||||
try {
|
|
||||||
if ((Get-Service -Name 'DPS').Status -ne [System.ServiceProcess.ServiceControllerStatus]::Running) {
|
|
||||||
Write-Output "Restarting the Diagnostic Policy Service (DPS)."
|
|
||||||
$dps | Start-Service
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
throw "Failed to restart the Diagnostic Policy Service (DPS). Error Details: $($_.Exception.Message)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-
|
-
|
||||||
name: Clear previous Windows installations
|
name: Clear previous Windows installations
|
||||||
call:
|
call:
|
||||||
@@ -15882,10 +15875,12 @@ actions:
|
|||||||
category: Advanced settings
|
category: Advanced settings
|
||||||
children:
|
children:
|
||||||
-
|
-
|
||||||
name: Set NTP (time) server to `pool.ntp.org` # Marked: stop-service-do-stuff-restart-service
|
name: Set NTP (time) server to `pool.ntp.org`
|
||||||
docs: https://www.pool.ntp.org/en/use.html
|
docs: https://www.pool.ntp.org/en/use.html
|
||||||
recommend: strict
|
recommend: strict
|
||||||
# `sc queryex` output is same in every OS language
|
# `sc queryex` output is same in every OS language
|
||||||
|
# Marked: refactor-with-revert-call, refactor-with-variables
|
||||||
|
# This would allow re-using `StartService` and `StopService`
|
||||||
code: |-
|
code: |-
|
||||||
:: Configure time source
|
:: Configure time source
|
||||||
w32tm /config /syncfromflags:manual /manualpeerlist:"0.pool.ntp.org 1.pool.ntp.org 2.pool.ntp.org 3.pool.ntp.org"
|
w32tm /config /syncfromflags:manual /manualpeerlist:"0.pool.ntp.org 1.pool.ntp.org 2.pool.ntp.org 3.pool.ntp.org"
|
||||||
@@ -15904,7 +15899,7 @@ actions:
|
|||||||
SC queryex "w32time"|Find "STATE"|Find /v "RUNNING">Nul||(
|
SC queryex "w32time"|Find "STATE"|Find /v "RUNNING">Nul||(
|
||||||
net stop w32time
|
net stop w32time
|
||||||
)
|
)
|
||||||
:: Start time servie and sync now
|
:: Start time service and sync now
|
||||||
net start w32time
|
net start w32time
|
||||||
w32tm /config /update
|
w32tm /config /update
|
||||||
w32tm /resync
|
w32tm /resync
|
||||||
@@ -16717,6 +16712,8 @@ functions:
|
|||||||
- name: defaultStartupMode # Allowed values: Boot | System | Automatic | Manual
|
- name: defaultStartupMode # Allowed values: Boot | System | Automatic | Manual
|
||||||
call:
|
call:
|
||||||
function: RunPowerShell
|
function: RunPowerShell
|
||||||
|
# Marked: refactor-with-revert-call, refactor-with-variables
|
||||||
|
# Implementation of those should share similar code: `DisableService`, `StopService`, `StartService`, `DisableServiceInRegistry`
|
||||||
parameters:
|
parameters:
|
||||||
code: |- # We do registry way because GUI, "sc config" or "Set-Service" won't not work
|
code: |- # We do registry way because GUI, "sc config" or "Set-Service" won't not work
|
||||||
$serviceQuery = '{{ $serviceName }}'
|
$serviceQuery = '{{ $serviceName }}'
|
||||||
@@ -16938,6 +16935,123 @@ functions:
|
|||||||
}
|
}
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
-
|
||||||
|
name: StopService
|
||||||
|
parameters:
|
||||||
|
- name: serviceName
|
||||||
|
- name: serviceRestartStateFile # This file is created only if the service is successfully stopped.
|
||||||
|
optional: true
|
||||||
|
- name: waitUntilStopped # Makes the script wait until the service is stopped
|
||||||
|
optional: true
|
||||||
|
call:
|
||||||
|
-
|
||||||
|
function: Comment
|
||||||
|
parameters:
|
||||||
|
codeComment: >-
|
||||||
|
Stop service: {{ $serviceName }}
|
||||||
|
{{ with $serviceRestartStateFile }}(with state flag){{ end }}
|
||||||
|
{{ with $waitUntilStopped }}(wait until stopped){{ end }}
|
||||||
|
-
|
||||||
|
function: RunPowerShell
|
||||||
|
parameters:
|
||||||
|
# Marked: refactor-with-variables
|
||||||
|
# Implementation of those should share similar code: `DisableService`, `StopService`, `StartService`, `DisableServiceInRegistry`
|
||||||
|
code: |-
|
||||||
|
$serviceName = '{{ $serviceName }}'
|
||||||
|
Write-Host "Stopping service: `"$serviceName`"."
|
||||||
|
$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue
|
||||||
|
if (!$service) {
|
||||||
|
Write-Host "Skipping, service `"$serviceName`" could not be not found, no need to stop it."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
if ($service.Status -ne [System.ServiceProcess.ServiceControllerStatus]::Running) {
|
||||||
|
Write-Host "Skipping, `"$serviceName`" is not running, no need to stop."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
Write-Host "`"$serviceName`" is running, stopping it."
|
||||||
|
try {
|
||||||
|
$service | Stop-Service -Force -ErrorAction Stop
|
||||||
|
{{ with $waitUntilStopped }}
|
||||||
|
$service.WaitForStatus([System.ServiceProcess.ServiceControllerStatus]::Stopped)
|
||||||
|
{{ end }}
|
||||||
|
} catch {
|
||||||
|
throw "Failed to stop the service `"$serviceName`": $_"
|
||||||
|
}
|
||||||
|
Write-Host "Successfully stopped the service: `"$serviceName`"."
|
||||||
|
{{ with $serviceRestartStateFile }}
|
||||||
|
$stateFilePath = '{{ . }}'
|
||||||
|
$expandedStateFilePath = [System.Environment]::ExpandEnvironmentVariables($stateFilePath)
|
||||||
|
if (Test-Path -Path $expandedStateFilePath) {
|
||||||
|
Write-Host "Skipping creating a service state file, it already exists: `"$expandedStateFilePath`"."
|
||||||
|
} else {
|
||||||
|
# Ensure the directory exists
|
||||||
|
$parentDirectory = [System.IO.Path]::GetDirectoryName($expandedStateFilePath)
|
||||||
|
if (-not (Test-Path $parentDirectory -PathType Container)) {
|
||||||
|
try {
|
||||||
|
New-Item -ItemType Directory -Path $parentDirectory -Force -ErrorAction Stop | Out-Null
|
||||||
|
} catch {
|
||||||
|
Write-Warning "Failed to create parent directory of service state file `"$parentDirectory`": $_"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Create the state file
|
||||||
|
try {
|
||||||
|
New-Item -ItemType File -Path $expandedStateFilePath -Force -ErrorAction Stop | Out-Null
|
||||||
|
Write-Host 'The service will be started again.'
|
||||||
|
} catch {
|
||||||
|
Write-Warning "Failed to create service state file `"$expandedStateFilePath`": $_"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{{ end }}
|
||||||
|
-
|
||||||
|
name: StartService
|
||||||
|
parameters:
|
||||||
|
- name: serviceName
|
||||||
|
- name: serviceRestartStateFile # Used for "check and delete": Starts the service only if file exists, always deletes the file.
|
||||||
|
optional: true
|
||||||
|
call:
|
||||||
|
-
|
||||||
|
function: Comment
|
||||||
|
parameters:
|
||||||
|
codeComment: >-
|
||||||
|
Start service: {{ $serviceName }}
|
||||||
|
{{ with $serviceRestartStateFile }}(with state flag){{ end }}
|
||||||
|
-
|
||||||
|
function: RunPowerShell
|
||||||
|
parameters:
|
||||||
|
# Marked: refactor-with-variables
|
||||||
|
# Implementation of those should share similar code: `DisableService`, `StopService`, `StartService`, `DisableServiceInRegistry`
|
||||||
|
code: |-
|
||||||
|
$serviceName = '{{ $serviceName }}'
|
||||||
|
{{ with $serviceRestartStateFile }}
|
||||||
|
$stateFilePath = '{{ . }}'
|
||||||
|
$expandedStateFilePath = [System.Environment]::ExpandEnvironmentVariables($stateFilePath)
|
||||||
|
if (-not (Test-Path -Path $expandedStateFilePath)) {
|
||||||
|
Write-Host "Skipping starting the service: It was not running before."
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
Remove-Item -Path $expandedStateFilePath -Force -ErrorAction Stop
|
||||||
|
Write-Host 'The service is expected to be started.'
|
||||||
|
} catch {
|
||||||
|
Write-Warning "Failed to delete the service state file `"$expandedStateFilePath`": $_"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{{ end }}
|
||||||
|
$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue
|
||||||
|
if (!$service) {
|
||||||
|
throw "Failed to start service `"$serviceName`": Service not found."
|
||||||
|
}
|
||||||
|
if ($service.Status -eq [System.ServiceProcess.ServiceControllerStatus]::Running) {
|
||||||
|
Write-Host "Skipping, `"$serviceName`" is already running, no need to start."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
Write-Host "`"$serviceName`" is not running, starting it."
|
||||||
|
try {
|
||||||
|
$service | Start-Service -ErrorAction Stop
|
||||||
|
Write-Host "Successfully started the service: `"$serviceName`"."
|
||||||
|
} catch {
|
||||||
|
Write-Warning "Failed to start the service: `"$serviceName`"."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
-
|
-
|
||||||
name: DisableService
|
name: DisableService
|
||||||
parameters:
|
parameters:
|
||||||
@@ -16950,6 +17064,8 @@ functions:
|
|||||||
codeComment: "Disable service(s): `{{ $serviceName }}`"
|
codeComment: "Disable service(s): `{{ $serviceName }}`"
|
||||||
revertCodeComment: "Restore service(s) to default state: `{{ $serviceName }}`"
|
revertCodeComment: "Restore service(s) to default state: `{{ $serviceName }}`"
|
||||||
-
|
-
|
||||||
|
# Marked: refactor-with-revert-call, refactor-with-variables
|
||||||
|
# Implementation of those should share similar code: `DisableService`, `StopService`, `StartService`, `DisableServiceInRegistry`
|
||||||
function: RunPowerShell
|
function: RunPowerShell
|
||||||
# Careful with Set-Service cmdlet:
|
# Careful with Set-Service cmdlet:
|
||||||
# 1. It exits with positive code even if service is disabled
|
# 1. It exits with positive code even if service is disabled
|
||||||
@@ -16959,7 +17075,7 @@ functions:
|
|||||||
# So "Disabled", "Automatic" and "Manual" are only consistent ones.
|
# So "Disabled", "Automatic" and "Manual" are only consistent ones.
|
||||||
# Read more:
|
# Read more:
|
||||||
# https://github.com/PowerShell/PowerShell/blob/v7.2.0/src/Microsoft.PowerShell.Commands.Management/commands/management/Service.cs#L2966-L2978
|
# https://github.com/PowerShell/PowerShell/blob/v7.2.0/src/Microsoft.PowerShell.Commands.Management/commands/management/Service.cs#L2966-L2978
|
||||||
# https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.management/set-service?view=powershell-7.1
|
# https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/set-service?view=powershell-7.4
|
||||||
parameters:
|
parameters:
|
||||||
code: |-
|
code: |-
|
||||||
$serviceName = '{{ $serviceName }}'
|
$serviceName = '{{ $serviceName }}'
|
||||||
@@ -16982,7 +17098,6 @@ functions:
|
|||||||
} else {
|
} else {
|
||||||
Write-Host "`"$serviceName`" is not running, no need to stop."
|
Write-Host "`"$serviceName`" is not running, no need to stop."
|
||||||
}
|
}
|
||||||
|
|
||||||
# -- 3. Skip if already disabled
|
# -- 3. Skip if already disabled
|
||||||
$startupType = $service.StartType # Does not work before .NET 4.6.1
|
$startupType = $service.StartType # Does not work before .NET 4.6.1
|
||||||
if(!$startupType) {
|
if(!$startupType) {
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
<template>
|
|
||||||
<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>
|
|
||||||
</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';
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
components: {
|
|
||||||
ScriptsTree,
|
|
||||||
FlatButton,
|
|
||||||
},
|
|
||||||
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 *;
|
|
||||||
|
|
||||||
$expanded-margin-top : 30px;
|
|
||||||
|
|
||||||
.card__expander {
|
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
position: relative;
|
|
||||||
background-color: $color-primary-darker;
|
|
||||||
color: $color-on-primary;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
margin-top: $expanded-margin-top;
|
|
||||||
|
|
||||||
.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: 0.25em;
|
|
||||||
@include clickable;
|
|
||||||
color: $color-primary-light;
|
|
||||||
@include hover-or-touch {
|
|
||||||
color: $color-primary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="arrow-container">
|
|
||||||
<div class="arrow" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
@use "@/presentation/assets/styles/main" as *;
|
|
||||||
|
|
||||||
$arrow-size : 15px;
|
|
||||||
|
|
||||||
.arrow-container {
|
|
||||||
position: relative;
|
|
||||||
.arrow {
|
|
||||||
position: absolute;
|
|
||||||
left: calc(50% - $arrow-size * 1.5);
|
|
||||||
top: calc(1.5 * $arrow-size);
|
|
||||||
border: solid $color-primary-darker;
|
|
||||||
border-width: 0 $arrow-size $arrow-size 0;
|
|
||||||
padding: $arrow-size;
|
|
||||||
transform: rotate(-135deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -19,7 +19,6 @@
|
|||||||
v-for="categoryId of categoryIds"
|
v-for="categoryId of categoryIds"
|
||||||
:key="categoryId"
|
:key="categoryId"
|
||||||
class="card"
|
class="card"
|
||||||
:total-cards-per-row="cardsPerRow"
|
|
||||||
:class="{
|
:class="{
|
||||||
'small-screen': width <= 500,
|
'small-screen': width <= 500,
|
||||||
'medium-screen': width > 500 && width < 750,
|
'medium-screen': width > 500 && width < 750,
|
||||||
@@ -63,19 +62,6 @@ export default defineComponent({
|
|||||||
);
|
);
|
||||||
const activeCategoryId = ref<number | undefined>(undefined);
|
const activeCategoryId = ref<number | undefined>(undefined);
|
||||||
|
|
||||||
const cardsPerRow = computed<number>(() => {
|
|
||||||
if (width.value === undefined) {
|
|
||||||
throw new Error('Unknown width, total cards should not be calculated');
|
|
||||||
}
|
|
||||||
if (width.value <= 500) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if (width.value < 750) {
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
return 3;
|
|
||||||
});
|
|
||||||
|
|
||||||
function onSelected(categoryId: number, isExpanded: boolean) {
|
function onSelected(categoryId: number, isExpanded: boolean) {
|
||||||
activeCategoryId.value = isExpanded ? categoryId : undefined;
|
activeCategoryId.value = isExpanded ? categoryId : undefined;
|
||||||
}
|
}
|
||||||
@@ -122,7 +108,6 @@ export default defineComponent({
|
|||||||
width,
|
width,
|
||||||
categoryIds,
|
categoryIds,
|
||||||
activeCategoryId,
|
activeCategoryId,
|
||||||
cardsPerRow,
|
|
||||||
onSelected,
|
onSelected,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,15 +29,20 @@
|
|||||||
:category-id="categoryId"
|
:category-id="categoryId"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CardExpansionPanelArrow v-show="isExpanded" />
|
<div class="card__expander" @click.stop>
|
||||||
<ExpandCollapseTransition>
|
<div class="card__expander__close-button">
|
||||||
<CardExpansionPanel
|
<FlatButton
|
||||||
v-show="isExpanded"
|
icon="xmark"
|
||||||
:category-id="categoryId"
|
@click="collapse()"
|
||||||
@on-collapse="collapse"
|
|
||||||
@click.stop
|
|
||||||
/>
|
/>
|
||||||
</ExpandCollapseTransition>
|
</div>
|
||||||
|
<div class="card__expander__content">
|
||||||
|
<ScriptsTree
|
||||||
|
:category-id="categoryId"
|
||||||
|
:has-top-padding="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -46,30 +51,24 @@ import {
|
|||||||
defineComponent, computed, shallowRef,
|
defineComponent, computed, shallowRef,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
|
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
|
||||||
import ExpandCollapseTransition from '@/presentation/components/Shared/ExpandCollapse/ExpandCollapseTransition.vue';
|
import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
|
||||||
import { injectKey } from '@/presentation/injectionSymbols';
|
import { injectKey } from '@/presentation/injectionSymbols';
|
||||||
|
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
|
||||||
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
|
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
|
||||||
import CardSelectionIndicator from './CardSelectionIndicator.vue';
|
import CardSelectionIndicator from './CardSelectionIndicator.vue';
|
||||||
import CardExpansionPanel from './CardExpansionPanel.vue';
|
|
||||||
import CardExpansionPanelArrow from './CardExpansionPanelArrow.vue';
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
|
ScriptsTree,
|
||||||
AppIcon,
|
AppIcon,
|
||||||
CardSelectionIndicator,
|
CardSelectionIndicator,
|
||||||
CardExpansionPanel,
|
FlatButton,
|
||||||
ExpandCollapseTransition,
|
|
||||||
CardExpansionPanelArrow,
|
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
categoryId: {
|
categoryId: {
|
||||||
type: Number,
|
type: Number,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
totalCardsPerRow: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
activeCategoryId: {
|
activeCategoryId: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: undefined,
|
default: undefined,
|
||||||
@@ -95,14 +94,6 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const cardWidth = computed<string>(() => {
|
|
||||||
const totalTimesGapIsUsedInRow = props.totalCardsPerRow - 1;
|
|
||||||
const totalGapWidthInRow = `calc(${totalTimesGapIsUsedInRow} * 15px)`; // TODO: 15px is hardcoded, $card-gap variable should be used
|
|
||||||
const availableRowWidthForCards = `calc(100% - (${totalGapWidthInRow}))`;
|
|
||||||
const availableWidthPerCard = `calc((${availableRowWidthForCards}) / ${totalTimesGapIsUsedInRow})`;
|
|
||||||
return availableWidthPerCard;
|
|
||||||
});
|
|
||||||
|
|
||||||
const cardElement = shallowRef<HTMLElement>();
|
const cardElement = shallowRef<HTMLElement>();
|
||||||
|
|
||||||
const cardTitle = computed<string>(() => {
|
const cardTitle = computed<string>(() => {
|
||||||
@@ -127,7 +118,6 @@ export default defineComponent({
|
|||||||
cardTitle,
|
cardTitle,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
cardElement,
|
cardElement,
|
||||||
cardWidth,
|
|
||||||
collapse,
|
collapse,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -141,22 +131,11 @@ export default defineComponent({
|
|||||||
$card-inner-padding : 30px;
|
$card-inner-padding : 30px;
|
||||||
$arrow-size : 15px;
|
$arrow-size : 15px;
|
||||||
$expanded-margin-top : 30px;
|
$expanded-margin-top : 30px;
|
||||||
|
$card-horizontal-gap : $card-gap;
|
||||||
.expansion__arrow {
|
|
||||||
position: relative;
|
|
||||||
.expansion__arrow__inner {
|
|
||||||
position: absolute;
|
|
||||||
left: calc(50% - $arrow-size * 1.5);
|
|
||||||
top: calc(1.5 * $arrow-size);
|
|
||||||
border: solid $color-primary-darker;
|
|
||||||
border-width: 0 $arrow-size $arrow-size 0;
|
|
||||||
padding: $arrow-size;
|
|
||||||
transform: rotate(-135deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
width: v-bind(cardWidth);
|
transition: all 0.2s ease-in-out;
|
||||||
|
|
||||||
&__inner {
|
&__inner {
|
||||||
padding-top: $card-inner-padding;
|
padding-top: $card-inner-padding;
|
||||||
padding-right: $card-inner-padding;
|
padding-right: $card-inner-padding;
|
||||||
@@ -181,6 +160,9 @@ $expanded-margin-top : 30px;
|
|||||||
color: $color-on-secondary;
|
color: $color-on-secondary;
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
&:after {
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
}
|
||||||
.card__inner__title {
|
.card__inner__title {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -202,12 +184,73 @@ $expanded-margin-top : 30px;
|
|||||||
font-size: $font-size-absolute-normal;
|
font-size: $font-size-absolute-normal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.card__expander {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
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: 0.25em;
|
||||||
|
@include clickable;
|
||||||
|
color: $color-primary-light;
|
||||||
|
@include hover-or-touch {
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-collapsed {
|
||||||
|
.card__inner {
|
||||||
|
&:after {
|
||||||
|
content: "";
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__expander {
|
||||||
|
max-height: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.is-expanded {
|
&.is-expanded {
|
||||||
.card__inner {
|
.card__inner {
|
||||||
height: auto;
|
height: auto;
|
||||||
background-color: $color-secondary;
|
background-color: $color-secondary;
|
||||||
color: $color-on-secondary;
|
color: $color-on-secondary;
|
||||||
|
&:after { // arrow
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(-1 * #{$expanded-margin-top});
|
||||||
|
left: calc(50% - #{$arrow-size});
|
||||||
|
border-left: #{$arrow-size} solid transparent;
|
||||||
|
border-right: #{$arrow-size} solid transparent;
|
||||||
|
border-bottom: #{$arrow-size} solid $color-primary-darker;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__expander {
|
||||||
|
min-height: 200px;
|
||||||
|
margin-top: $expanded-margin-top;
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include hover-or-touch {
|
@include hover-or-touch {
|
||||||
@@ -234,26 +277,26 @@ $expanded-margin-top : 30px;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@mixin adaptive-card($cards-in-row) {
|
@mixin adaptive-card($cards-in-row) {
|
||||||
.card {
|
&.card {
|
||||||
$total-times-gap-is-used-in-row: $cards-in-row - 1;
|
$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;
|
$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-row-width-for-cards: calc(100% - #{$total-gap-width-in-row});
|
||||||
$available-width-per-card: calc(#{$available-row-width-for-cards} / #{$cards-in-row});
|
$available-width-per-card: calc(#{$available-row-width-for-cards} / #{$cards-in-row});
|
||||||
width:$available-width-per-card;
|
width:$available-width-per-card;
|
||||||
// .card__expander {
|
.card__expander {
|
||||||
// $all-cards-width: 100% * $cards-in-row;
|
$all-cards-width: 100% * $cards-in-row;
|
||||||
// $additional-padding-width: $card-horizontal-gap * ($cards-in-row - 1);
|
$additional-padding-width: $card-horizontal-gap * ($cards-in-row - 1);
|
||||||
// width: calc(#{$all-cards-width} + #{$additional-padding-width});
|
width: calc(#{$all-cards-width} + #{$additional-padding-width});
|
||||||
// }
|
}
|
||||||
// @for $nth-card from 2 through $cards-in-row { // From second card to rest
|
@for $nth-card from 2 through $cards-in-row { // From second card to rest
|
||||||
// &:nth-of-type(#{$cards-in-row}n+#{$nth-card}) {
|
&:nth-of-type(#{$cards-in-row}n+#{$nth-card}) {
|
||||||
// .card__expander {
|
.card__expander {
|
||||||
// $card-left: -100% * ($nth-card - 1);
|
$card-left: -100% * ($nth-card - 1);
|
||||||
// $additional-space: $card-horizontal-gap * ($nth-card - 1);
|
$additional-space: $card-horizontal-gap * ($nth-card - 1);
|
||||||
// margin-left: calc(#{$card-left} - #{$additional-space});
|
margin-left: calc(#{$card-left} - #{$additional-space});
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
// Ensure new line after last row
|
// Ensure new line after last row
|
||||||
$card-after-last: $cards-in-row + 1;
|
$card-after-last: $cards-in-row + 1;
|
||||||
&:nth-of-type(#{$cards-in-row}n+#{$card-after-last}) {
|
&:nth-of-type(#{$cards-in-row}n+#{$card-after-last}) {
|
||||||
@@ -261,4 +304,8 @@ $expanded-margin-top : 30px;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.big-screen { @include adaptive-card(3); }
|
||||||
|
.medium-screen { @include adaptive-card(2); }
|
||||||
|
.small-screen { @include adaptive-card(1); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
69
tests/checks/external-urls/DocumentationUrlExtractor.ts
Normal file
69
tests/checks/external-urls/DocumentationUrlExtractor.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import type { IApplication } from '@/domain/IApplication';
|
||||||
|
import type { TestExecutionDetailsLogger } from './TestExecutionDetailsLogger';
|
||||||
|
|
||||||
|
interface UrlExtractionContext {
|
||||||
|
readonly logger: TestExecutionDetailsLogger;
|
||||||
|
readonly application: IApplication;
|
||||||
|
readonly urlExclusionPatterns: readonly RegExp[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractDocumentationUrls(
|
||||||
|
context: UrlExtractionContext,
|
||||||
|
): string[] {
|
||||||
|
const urlsInApplication = extractUrlsFromApplication(context.application);
|
||||||
|
context.logger.logLabeledInformation(
|
||||||
|
'Extracted URLs from application',
|
||||||
|
urlsInApplication.length.toString(),
|
||||||
|
);
|
||||||
|
const uniqueUrls = filterDuplicateUrls(urlsInApplication);
|
||||||
|
context.logger.logLabeledInformation(
|
||||||
|
'Unique URLs after deduplication',
|
||||||
|
`${uniqueUrls.length} (duplicates removed)`,
|
||||||
|
);
|
||||||
|
context.logger.logLabeledInformation(
|
||||||
|
'Exclusion patterns for URLs',
|
||||||
|
context.urlExclusionPatterns.length === 0
|
||||||
|
? 'None (all URLs included)'
|
||||||
|
: context.urlExclusionPatterns.map((pattern, index) => `${index + 1}) ${pattern.toString()}`).join('\n'),
|
||||||
|
);
|
||||||
|
const includedUrls = filterUrlsExcludingPatterns(uniqueUrls, context.urlExclusionPatterns);
|
||||||
|
context.logger.logLabeledInformation(
|
||||||
|
'URLs extracted for testing',
|
||||||
|
`${includedUrls.length} (after applying exclusion patterns; ${uniqueUrls.length - includedUrls.length} URLs ignored)`,
|
||||||
|
);
|
||||||
|
return includedUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUrlsFromApplication(application: IApplication): string[] {
|
||||||
|
return [ // Get all executables
|
||||||
|
...application.collections.flatMap((c) => c.getAllCategories()),
|
||||||
|
...application.collections.flatMap((c) => c.getAllScripts()),
|
||||||
|
]
|
||||||
|
// Get all docs
|
||||||
|
.flatMap((documentable) => documentable.docs)
|
||||||
|
// Parse all URLs
|
||||||
|
.flatMap((docString) => extractUrlsExcludingCodeBlocks(docString));
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterDuplicateUrls(urls: readonly string[]): string[] {
|
||||||
|
return urls.filter((url, index, array) => array.indexOf(url) === index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterUrlsExcludingPatterns(
|
||||||
|
urls: readonly string[],
|
||||||
|
patterns: readonly RegExp[],
|
||||||
|
): string[] {
|
||||||
|
return urls.filter((url) => !patterns.some((pattern) => pattern.test(url)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUrlsExcludingCodeBlocks(textWithInlineCode: string): string[] {
|
||||||
|
/*
|
||||||
|
Matches URLs:
|
||||||
|
- Excludes inline code blocks as they may contain URLs not intended for user interaction
|
||||||
|
and not guaranteed to support expected HTTP methods, leading to false-negatives.
|
||||||
|
- Supports URLs containing parentheses, avoiding matches within code that might not represent
|
||||||
|
actual links.
|
||||||
|
*/
|
||||||
|
const nonCodeBlockUrlRegex = /(?<!`)(https?:\/\/[^\s`"<>()]+(?:\([^\s`"<>()]*\))?[^\s`"<>()]*)/g;
|
||||||
|
return textWithInlineCode.match(nonCodeBlockUrlRegex) || [];
|
||||||
|
}
|
||||||
@@ -10,7 +10,10 @@ export async function getUrlStatusesInParallel(
|
|||||||
): Promise<UrlStatus[]> {
|
): Promise<UrlStatus[]> {
|
||||||
// urls = ['https://privacy.sexy']; // Comment out this line to use a hardcoded URL for testing.
|
// urls = ['https://privacy.sexy']; // Comment out this line to use a hardcoded URL for testing.
|
||||||
const uniqueUrls = Array.from(new Set(urls));
|
const uniqueUrls = Array.from(new Set(urls));
|
||||||
const defaultedDomainOptions = { ...DefaultDomainOptions, ...options?.domainOptions };
|
const defaultedDomainOptions: Required<DomainOptions> = {
|
||||||
|
...DefaultDomainOptions,
|
||||||
|
...options?.domainOptions,
|
||||||
|
};
|
||||||
console.log('Batch request options applied:', defaultedDomainOptions);
|
console.log('Batch request options applied:', defaultedDomainOptions);
|
||||||
const results = await request(uniqueUrls, defaultedDomainOptions, options);
|
const results = await request(uniqueUrls, defaultedDomainOptions, options);
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { indentText } from '@tests/shared/Text';
|
||||||
import { fetchWithTimeout } from './FetchWithTimeout';
|
import { fetchWithTimeout } from './FetchWithTimeout';
|
||||||
import { getDomainFromUrl } from './UrlDomainProcessing';
|
import { getDomainFromUrl } from './UrlDomainProcessing';
|
||||||
|
|
||||||
@@ -7,8 +8,12 @@ export function fetchFollow(
|
|||||||
fetchOptions?: Partial<RequestInit>,
|
fetchOptions?: Partial<RequestInit>,
|
||||||
followOptions?: Partial<FollowOptions>,
|
followOptions?: Partial<FollowOptions>,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const defaultedFollowOptions = { ...DefaultFollowOptions, ...followOptions };
|
const defaultedFollowOptions: Required<FollowOptions> = {
|
||||||
if (followRedirects(defaultedFollowOptions)) {
|
...DefaultFollowOptions,
|
||||||
|
...followOptions,
|
||||||
|
};
|
||||||
|
console.log(indentText(`Follow options: ${JSON.stringify(defaultedFollowOptions)}`));
|
||||||
|
if (!followRedirects(defaultedFollowOptions)) {
|
||||||
return fetchWithTimeout(url, timeoutInMs, fetchOptions);
|
return fetchWithTimeout(url, timeoutInMs, fetchOptions);
|
||||||
}
|
}
|
||||||
fetchOptions = { ...fetchOptions, redirect: 'manual' /* handled manually */, mode: 'cors' };
|
fetchOptions = { ...fetchOptions, redirect: 'manual' /* handled manually */, mode: 'cors' };
|
||||||
@@ -22,8 +27,6 @@ export function fetchFollow(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// "cors" | "navigate" | "no-cors" | "same-origin";
|
|
||||||
|
|
||||||
export interface FollowOptions {
|
export interface FollowOptions {
|
||||||
readonly followRedirects?: boolean;
|
readonly followRedirects?: boolean;
|
||||||
readonly maximumRedirectFollowDepth?: number;
|
readonly maximumRedirectFollowDepth?: number;
|
||||||
@@ -98,11 +101,11 @@ class CookieStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function followRedirects(options: FollowOptions): boolean {
|
function followRedirects(options: FollowOptions): boolean {
|
||||||
if (!options.followRedirects) {
|
if (options.followRedirects !== true) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (options.maximumRedirectFollowDepth === 0) {
|
if (options.maximumRedirectFollowDepth === undefined || options.maximumRedirectFollowDepth <= 0) {
|
||||||
return false;
|
throw new Error('Invalid followRedirects configuration: maximumRedirectFollowDepth must be a positive integer');
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,3 +100,10 @@ console.log(`Status code: ${status.code}`);
|
|||||||
- **`enableCookies`** (*boolean*), default: `true`
|
- **`enableCookies`** (*boolean*), default: `true`
|
||||||
- Enables cookie storage to facilitate seamless navigation through login or other authentication challenges.
|
- Enables cookie storage to facilitate seamless navigation through login or other authentication challenges.
|
||||||
- 💡 Helps to over-come sign-in challenges with callbacks.
|
- 💡 Helps to over-come sign-in challenges with callbacks.
|
||||||
|
- **`forceHttpGetForUrlPatterns`** (*array*), default: `[]`
|
||||||
|
- Specifies URL patterns that should always use an HTTP GET request instead of the default HTTP HEAD.
|
||||||
|
- 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\//`
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface RequestOptions {
|
|||||||
readonly additionalHeadersUrlIgnore?: string[];
|
readonly additionalHeadersUrlIgnore?: string[];
|
||||||
readonly requestTimeoutInMs: number;
|
readonly requestTimeoutInMs: number;
|
||||||
readonly randomizeTlsFingerprint: boolean;
|
readonly randomizeTlsFingerprint: boolean;
|
||||||
|
readonly forceHttpGetForUrlPatterns: RegExp[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DefaultOptions: Required<RequestOptions> = {
|
const DefaultOptions: Required<RequestOptions> = {
|
||||||
@@ -32,6 +33,7 @@ const DefaultOptions: Required<RequestOptions> = {
|
|||||||
additionalHeadersUrlIgnore: [],
|
additionalHeadersUrlIgnore: [],
|
||||||
requestTimeoutInMs: 60 /* seconds */ * 1000,
|
requestTimeoutInMs: 60 /* seconds */ * 1000,
|
||||||
randomizeTlsFingerprint: true,
|
randomizeTlsFingerprint: true,
|
||||||
|
forceHttpGetForUrlPatterns: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
function fetchUrlStatusWithRetry(
|
function fetchUrlStatusWithRetry(
|
||||||
@@ -41,7 +43,11 @@ function fetchUrlStatusWithRetry(
|
|||||||
): Promise<UrlStatus> {
|
): Promise<UrlStatus> {
|
||||||
const fetchOptions = getFetchOptions(url, requestOptions);
|
const fetchOptions = getFetchOptions(url, requestOptions);
|
||||||
return retryWithExponentialBackOff(async () => {
|
return retryWithExponentialBackOff(async () => {
|
||||||
console.log(`Initiating request for URL: ${url}`);
|
console.log(`🚀 Initiating request for URL: ${url}`);
|
||||||
|
console.log(indentText([
|
||||||
|
`HTTP method: ${fetchOptions.method}`,
|
||||||
|
`Request options: ${JSON.stringify(requestOptions)}`,
|
||||||
|
].join('\n')));
|
||||||
let result: UrlStatus;
|
let result: UrlStatus;
|
||||||
try {
|
try {
|
||||||
const response = await fetchFollow(
|
const response = await fetchFollow(
|
||||||
@@ -56,7 +62,8 @@ function fetchUrlStatusWithRetry(
|
|||||||
url,
|
url,
|
||||||
error: [
|
error: [
|
||||||
'Error:', indentText(JSON.stringify(err, null, '\t') || err.toString()),
|
'Error:', indentText(JSON.stringify(err, null, '\t') || err.toString()),
|
||||||
'Options:', indentText(JSON.stringify(fetchOptions, null, '\t')),
|
'Fetch options:', indentText(JSON.stringify(fetchOptions, null, '\t')),
|
||||||
|
'Request options:', indentText(JSON.stringify(requestOptions, null, '\t')),
|
||||||
'TLS:', indentText(getTlsContextInfo()),
|
'TLS:', indentText(getTlsContextInfo()),
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
};
|
};
|
||||||
@@ -71,7 +78,7 @@ function getFetchOptions(url: string, options: Required<RequestOptions>): Reques
|
|||||||
? {}
|
? {}
|
||||||
: options.additionalHeaders;
|
: options.additionalHeaders;
|
||||||
return {
|
return {
|
||||||
method: 'GET', // Fetch only headers without the full response body for better speed
|
method: getHttpMethod(url, options),
|
||||||
headers: {
|
headers: {
|
||||||
...getDefaultHeaders(url),
|
...getDefaultHeaders(url),
|
||||||
...additionalHeaders,
|
...additionalHeaders,
|
||||||
@@ -80,6 +87,14 @@ function getFetchOptions(url: string, options: Required<RequestOptions>): Reques
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getHttpMethod(url: string, options: Required<RequestOptions>): 'HEAD' | 'GET' {
|
||||||
|
if (options.forceHttpGetForUrlPatterns.some((pattern) => url.match(pattern))) {
|
||||||
|
return 'GET';
|
||||||
|
}
|
||||||
|
// By default fetch only headers without the full response body for better speed
|
||||||
|
return 'HEAD';
|
||||||
|
}
|
||||||
|
|
||||||
function getDefaultHeaders(url: string): Record<string, string> {
|
function getDefaultHeaders(url: string): Record<string, string> {
|
||||||
return {
|
return {
|
||||||
// Needed for websites that filter out non-browser user agents.
|
// Needed for websites that filter out non-browser user agents.
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ import { indentText } from '@tests/shared/Text';
|
|||||||
|
|
||||||
export function randomizeTlsFingerprint() {
|
export function randomizeTlsFingerprint() {
|
||||||
tls.DEFAULT_CIPHERS = getShuffledCiphers().join(':');
|
tls.DEFAULT_CIPHERS = getShuffledCiphers().join(':');
|
||||||
console.log(
|
console.log(indentText(
|
||||||
[
|
`TLS context:\n${indentText([
|
||||||
'Original ciphers:', indentText(constants.defaultCipherList),
|
'Original ciphers:', indentText(constants.defaultCipherList),
|
||||||
'Current context', indentText(getTlsContextInfo()),
|
'Current ciphers:', indentText(getTlsContextInfo()),
|
||||||
].join('\n'),
|
].join('\n'))}`,
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTlsContextInfo(): string {
|
export function getTlsContextInfo(): string {
|
||||||
|
|||||||
26
tests/checks/external-urls/TestExecutionDetailsLogger.ts
Normal file
26
tests/checks/external-urls/TestExecutionDetailsLogger.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { indentText } from '@tests/shared/Text';
|
||||||
|
|
||||||
|
export class TestExecutionDetailsLogger {
|
||||||
|
public logTestSectionStartDelimiter(): void {
|
||||||
|
this.logSectionDelimiterLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
public logTestSectionEndDelimiter(): void {
|
||||||
|
this.logSectionDelimiterLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
public logLabeledInformation(
|
||||||
|
label: string,
|
||||||
|
detailedInformation: string,
|
||||||
|
): void {
|
||||||
|
console.log([
|
||||||
|
`${label}:`,
|
||||||
|
indentText(detailedInformation),
|
||||||
|
].join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private logSectionDelimiterLine(): void {
|
||||||
|
const horizontalLine = '─'.repeat(40);
|
||||||
|
console.log(horizontalLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,26 @@
|
|||||||
import { test, expect } from 'vitest';
|
import { test, expect } from 'vitest';
|
||||||
import { parseApplication } from '@/application/Parser/ApplicationParser';
|
import { parseApplication } from '@/application/Parser/ApplicationParser';
|
||||||
import type { IApplication } from '@/domain/IApplication';
|
|
||||||
import { indentText } from '@tests/shared/Text';
|
import { indentText } from '@tests/shared/Text';
|
||||||
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||||
|
import { shuffle } from '@/application/Common/Shuffle';
|
||||||
import { type UrlStatus, formatUrlStatus } from './StatusChecker/UrlStatus';
|
import { type UrlStatus, formatUrlStatus } from './StatusChecker/UrlStatus';
|
||||||
import { getUrlStatusesInParallel, type BatchRequestOptions } from './StatusChecker/BatchStatusChecker';
|
import { getUrlStatusesInParallel, type BatchRequestOptions } from './StatusChecker/BatchStatusChecker';
|
||||||
|
import { TestExecutionDetailsLogger } from './TestExecutionDetailsLogger';
|
||||||
|
import { extractDocumentationUrls } from './DocumentationUrlExtractor';
|
||||||
|
|
||||||
// arrange
|
// arrange
|
||||||
|
const logger = new TestExecutionDetailsLogger();
|
||||||
|
logger.logTestSectionStartDelimiter();
|
||||||
const app = parseApplication();
|
const app = parseApplication();
|
||||||
const urls = collectUniqueUrls({
|
let urls = extractDocumentationUrls({
|
||||||
application: app,
|
logger,
|
||||||
excludePatterns: [
|
urlExclusionPatterns: [
|
||||||
/^https:\/\/archive\.ph/, // Drops HEAD/GET requests via fetch/curl, responding to Postman/Chromium.
|
/^https:\/\/archive\.ph/, // Drops HEAD/GET requests via fetch/curl, responding to Postman/Chromium.
|
||||||
],
|
],
|
||||||
|
application: app,
|
||||||
});
|
});
|
||||||
|
urls = filterUrlsToEnvironmentCheckLimit(urls);
|
||||||
|
logger.logLabeledInformation('URLs submitted for testing', urls.length.toString());
|
||||||
const requestOptions: BatchRequestOptions = {
|
const requestOptions: BatchRequestOptions = {
|
||||||
domainOptions: {
|
domainOptions: {
|
||||||
sameDomainParallelize: false, // be nice to our third-party servers
|
sameDomainParallelize: false, // be nice to our third-party servers
|
||||||
@@ -30,53 +37,65 @@ const requestOptions: BatchRequestOptions = {
|
|||||||
enableCookies: true,
|
enableCookies: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
logger.logLabeledInformation('HTTP request options', JSON.stringify(requestOptions, null, 2));
|
||||||
const testTimeoutInMs = urls.length * 60 /* seconds */ * 1000;
|
const testTimeoutInMs = urls.length * 60 /* seconds */ * 1000;
|
||||||
|
logger.logLabeledInformation('Scheduled test duration', convertMillisecondsToHumanReadableFormat(testTimeoutInMs));
|
||||||
|
logger.logTestSectionEndDelimiter();
|
||||||
test(`all URLs (${urls.length}) should be alive`, async () => {
|
test(`all URLs (${urls.length}) should be alive`, async () => {
|
||||||
// act
|
// act
|
||||||
|
console.log('URLS', urls); // TODO: Delete
|
||||||
const results = await getUrlStatusesInParallel(urls, requestOptions);
|
const results = await getUrlStatusesInParallel(urls, requestOptions);
|
||||||
// assert
|
// assert
|
||||||
const deadUrls = results.filter((r) => r.code === undefined || !isOkStatusCode(r.code));
|
const deadUrls = results.filter((r) => r.code === undefined || !isOkStatusCode(r.code));
|
||||||
expect(deadUrls).to.have.lengthOf(0, formatAssertionMessage([formatUrlStatusReport(deadUrls)]));
|
expect(deadUrls).to.have.lengthOf(
|
||||||
|
0,
|
||||||
|
formatAssertionMessage([createReportForDeadUrlStatuses(deadUrls)]),
|
||||||
|
);
|
||||||
}, testTimeoutInMs);
|
}, testTimeoutInMs);
|
||||||
|
|
||||||
function isOkStatusCode(statusCode: number): boolean {
|
function isOkStatusCode(statusCode: number): boolean {
|
||||||
return statusCode >= 200 && statusCode < 300;
|
return statusCode >= 200 && statusCode < 300;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectUniqueUrls(
|
function createReportForDeadUrlStatuses(deadUrlStatuses: readonly UrlStatus[]): string {
|
||||||
options: {
|
|
||||||
readonly application: IApplication,
|
|
||||||
readonly excludePatterns?: readonly RegExp[],
|
|
||||||
},
|
|
||||||
): string[] {
|
|
||||||
return [ // Get all nodes
|
|
||||||
...options.application.collections.flatMap((c) => c.getAllCategories()),
|
|
||||||
...options.application.collections.flatMap((c) => c.getAllScripts()),
|
|
||||||
]
|
|
||||||
// Get all docs
|
|
||||||
.flatMap((documentable) => documentable.docs)
|
|
||||||
// Parse all URLs
|
|
||||||
.flatMap((docString) => extractUrls(docString))
|
|
||||||
// Remove duplicates
|
|
||||||
.filter((url, index, array) => array.indexOf(url) === index)
|
|
||||||
// Exclude certain URLs based on patterns
|
|
||||||
.filter((url) => !shouldExcludeUrl(url, options.excludePatterns ?? []));
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldExcludeUrl(url: string, patterns: readonly RegExp[]): boolean {
|
|
||||||
return patterns.some((pattern) => pattern.test(url));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUrlStatusReport(deadUrlStatuses: readonly UrlStatus[]): string {
|
|
||||||
return `\n${deadUrlStatuses.map((status) => indentText(formatUrlStatus(status))).join('\n---\n')}\n`;
|
return `\n${deadUrlStatuses.map((status) => indentText(formatUrlStatus(status))).join('\n---\n')}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractUrls(textWithInlineCode: string): string[] {
|
function filterUrlsToEnvironmentCheckLimit(originalUrls: string[]): string[] {
|
||||||
/*
|
const { RANDOMIZED_URL_CHECK_LIMIT } = process.env;
|
||||||
Matches all URLs.
|
logger.logLabeledInformation('URL check limit', RANDOMIZED_URL_CHECK_LIMIT || 'Unlimited');
|
||||||
Inline code blocks contain URLs not intended for user interaction and not
|
if (RANDOMIZED_URL_CHECK_LIMIT !== undefined && RANDOMIZED_URL_CHECK_LIMIT !== '') {
|
||||||
guaranteed to support expected HTTP methods, leading to false-negatives.
|
const maxUrlsInTest = parseInt(RANDOMIZED_URL_CHECK_LIMIT, 10);
|
||||||
*/
|
if (Number.isNaN(maxUrlsInTest)) {
|
||||||
const nonCodeBlockUrlRegex = /(?<!`)(https?:\/\/[^\s`"<>()]+)/g;
|
throw new Error(`Invalid URL limit: ${RANDOMIZED_URL_CHECK_LIMIT}`);
|
||||||
return textWithInlineCode.match(nonCodeBlockUrlRegex) || [];
|
}
|
||||||
|
if (maxUrlsInTest < originalUrls.length) {
|
||||||
|
return shuffle(originalUrls).slice(0, maxUrlsInTest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return originalUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertMillisecondsToHumanReadableFormat(milliseconds: number): string {
|
||||||
|
const timeParts: string[] = [];
|
||||||
|
const addTimePart = (amount: number, label: string) => {
|
||||||
|
if (amount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timeParts.push(`${amount} ${label}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hours = milliseconds / (1000 * 60 * 60);
|
||||||
|
const absoluteHours = Math.floor(hours);
|
||||||
|
addTimePart(absoluteHours, 'hours');
|
||||||
|
|
||||||
|
const minutes = (hours - absoluteHours) * 60;
|
||||||
|
const absoluteMinutes = Math.floor(minutes);
|
||||||
|
addTimePart(absoluteMinutes, 'minutes');
|
||||||
|
|
||||||
|
const seconds = (minutes - absoluteMinutes) * 60;
|
||||||
|
const absoluteSeconds = Math.floor(seconds);
|
||||||
|
addTimePart(absoluteSeconds, 'seconds');
|
||||||
|
|
||||||
|
return timeParts.join(', ');
|
||||||
}
|
}
|
||||||
|
|||||||
52
tests/unit/application/Common/Shuffle.spec.ts
Normal file
52
tests/unit/application/Common/Shuffle.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { shuffle } from '@/application/Common/Shuffle';
|
||||||
|
|
||||||
|
describe('Shuffle', () => {
|
||||||
|
describe('shuffle', () => {
|
||||||
|
it('returns a new array', () => {
|
||||||
|
// arrange
|
||||||
|
const inputArray = ['a', 'b', 'c', 'd'];
|
||||||
|
// act
|
||||||
|
const result = shuffle(inputArray);
|
||||||
|
// assert
|
||||||
|
expect(result).not.to.equal(inputArray);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an array of the same length', () => {
|
||||||
|
// arrange
|
||||||
|
const inputArray = ['a', 'b', 'c', 'd'];
|
||||||
|
// act
|
||||||
|
const result = shuffle(inputArray);
|
||||||
|
// assert
|
||||||
|
expect(result.length).toBe(inputArray.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains the same elements', () => {
|
||||||
|
// arrange
|
||||||
|
const inputArray = ['a', 'b', 'c', 'd'];
|
||||||
|
// act
|
||||||
|
const result = shuffle(inputArray);
|
||||||
|
// assert
|
||||||
|
expect(result).to.have.members(inputArray);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not modify the input array', () => {
|
||||||
|
// arrange
|
||||||
|
const inputArray = ['a', 'b', 'c', 'd'];
|
||||||
|
const inputArrayCopy = [...inputArray];
|
||||||
|
// act
|
||||||
|
shuffle(inputArray);
|
||||||
|
// assert
|
||||||
|
expect(inputArray).to.deep.equal(inputArrayCopy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles an empty array correctly', () => {
|
||||||
|
// arrange
|
||||||
|
const inputArray: string[] = [];
|
||||||
|
// act
|
||||||
|
const result = shuffle(inputArray);
|
||||||
|
// assert
|
||||||
|
expect(result).have.lengthOf(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { FunctionCallArgumentCollection } from '@/application/Parser/Script/Compiler/Function/Call/Argument/FunctionCallArgumentCollection';
|
import { FunctionCallArgumentCollection } from '@/application/Parser/Script/Compiler/Function/Call/Argument/FunctionCallArgumentCollection';
|
||||||
import { FunctionCallArgumentStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentStub';
|
import { FunctionCallArgumentStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentStub';
|
||||||
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||||
|
import type { IFunctionCallArgument } from '@/application/Parser/Script/Compiler/Function/Call/Argument/IFunctionCallArgument';
|
||||||
|
|
||||||
describe('FunctionCallArgumentCollection', () => {
|
describe('FunctionCallArgumentCollection', () => {
|
||||||
describe('addArgument', () => {
|
describe('addArgument', () => {
|
||||||
@@ -20,21 +21,25 @@ describe('FunctionCallArgumentCollection', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('getAllParameterNames', () => {
|
describe('getAllParameterNames', () => {
|
||||||
it('returns as expected', () => {
|
describe('returns as expected', () => {
|
||||||
// arrange
|
// arrange
|
||||||
const testCases = [{
|
const testCases: ReadonlyArray<{
|
||||||
name: 'no args',
|
readonly description: string;
|
||||||
|
readonly args: readonly IFunctionCallArgument[];
|
||||||
|
readonly expectedParameterNames: string[];
|
||||||
|
}> = [{
|
||||||
|
description: 'no args',
|
||||||
args: [],
|
args: [],
|
||||||
expected: [],
|
expectedParameterNames: [],
|
||||||
}, {
|
}, {
|
||||||
name: 'with some args',
|
description: 'with some args',
|
||||||
args: [
|
args: [
|
||||||
new FunctionCallArgumentStub().withParameterName('a-param-name'),
|
new FunctionCallArgumentStub().withParameterName('a-param-name'),
|
||||||
new FunctionCallArgumentStub().withParameterName('b-param-name')],
|
new FunctionCallArgumentStub().withParameterName('b-param-name')],
|
||||||
expected: ['a-param-name', 'b-param-name'],
|
expectedParameterNames: ['a-param-name', 'b-param-name'],
|
||||||
}];
|
}];
|
||||||
for (const testCase of testCases) {
|
for (const testCase of testCases) {
|
||||||
it(testCase.name, () => {
|
it(testCase.description, () => {
|
||||||
const sut = new FunctionCallArgumentCollection();
|
const sut = new FunctionCallArgumentCollection();
|
||||||
// act
|
// act
|
||||||
for (const arg of testCase.args) {
|
for (const arg of testCase.args) {
|
||||||
@@ -42,7 +47,7 @@ describe('FunctionCallArgumentCollection', () => {
|
|||||||
}
|
}
|
||||||
const actual = sut.getAllParameterNames();
|
const actual = sut.getAllParameterNames();
|
||||||
// assert
|
// assert
|
||||||
expect(actual).to.equal(testCase.expected);
|
expect(actual).to.deep.equal(testCase.expectedParameterNames);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe('SharedFunction', () => {
|
|||||||
// assert
|
// assert
|
||||||
expect(sut.name).equal(expected);
|
expect(sut.name).equal(expected);
|
||||||
});
|
});
|
||||||
it('throws when absent', () => {
|
describe('throws when absent', () => {
|
||||||
itEachAbsentStringValue((absentValue) => {
|
itEachAbsentStringValue((absentValue) => {
|
||||||
// arrange
|
// arrange
|
||||||
const expectedError = 'missing function name';
|
const expectedError = 'missing function name';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { InjectionKeys } from '@/presentation/injectionSymbols';
|
|||||||
import { provideDependencies, type VueDependencyInjectionApi } from '@/presentation/bootstrapping/DependencyProvider';
|
import { provideDependencies, type VueDependencyInjectionApi } from '@/presentation/bootstrapping/DependencyProvider';
|
||||||
import { ApplicationContextStub } from '@tests/unit/shared/Stubs/ApplicationContextStub';
|
import { ApplicationContextStub } from '@tests/unit/shared/Stubs/ApplicationContextStub';
|
||||||
import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests';
|
import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests';
|
||||||
|
import type { IApplicationContext } from '@/application/Context/IApplicationContext';
|
||||||
|
|
||||||
describe('DependencyProvider', () => {
|
describe('DependencyProvider', () => {
|
||||||
describe('provideDependencies', () => {
|
describe('provideDependencies', () => {
|
||||||
@@ -74,25 +75,25 @@ function createSingletonTests() {
|
|||||||
const registeredObject = api.inject(injectionKey);
|
const registeredObject = api.inject(injectionKey);
|
||||||
expect(registeredObject).to.be.instanceOf(Object);
|
expect(registeredObject).to.be.instanceOf(Object);
|
||||||
});
|
});
|
||||||
it('should return the same instance for singleton dependency', () => {
|
describe('should return the same instance for singleton dependency', () => {
|
||||||
itIsSingleton({
|
|
||||||
getter: () => {
|
|
||||||
// arrange
|
// arrange
|
||||||
|
const singletonContext = new ApplicationContextStub();
|
||||||
const api = new VueDependencyInjectionApiStub();
|
const api = new VueDependencyInjectionApiStub();
|
||||||
// act
|
|
||||||
new ProvideDependenciesBuilder()
|
new ProvideDependenciesBuilder()
|
||||||
|
.withContext(singletonContext)
|
||||||
.withApi(api)
|
.withApi(api)
|
||||||
.provideDependencies();
|
.provideDependencies();
|
||||||
// expect
|
// act
|
||||||
const registeredObject = api.inject(injectionKey);
|
const getRegisteredInstance = () => api.inject(injectionKey);
|
||||||
return registeredObject;
|
// assert
|
||||||
},
|
itIsSingleton({
|
||||||
|
getter: getRegisteredInstance,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
class ProvideDependenciesBuilder {
|
class ProvideDependenciesBuilder {
|
||||||
private context = new ApplicationContextStub();
|
private context: IApplicationContext = new ApplicationContextStub();
|
||||||
|
|
||||||
private api: VueDependencyInjectionApi = new VueDependencyInjectionApiStub();
|
private api: VueDependencyInjectionApi = new VueDependencyInjectionApiStub();
|
||||||
|
|
||||||
@@ -101,6 +102,11 @@ class ProvideDependenciesBuilder {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public withContext(context: IApplicationContext): this {
|
||||||
|
this.context = context;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public provideDependencies() {
|
public provideDependencies() {
|
||||||
return provideDependencies(this.context, this.api);
|
return provideDependencies(this.context, this.api);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { TreeInputNodeData } from '@/presentation/components/Scripts/View/T
|
|||||||
|
|
||||||
describe('TreeNodeInitializerAndUpdater', () => {
|
describe('TreeNodeInitializerAndUpdater', () => {
|
||||||
describe('updateRootNodes', () => {
|
describe('updateRootNodes', () => {
|
||||||
it('should throw an error if no data is provided', () => {
|
describe('should throw an error if no data is provided', () => {
|
||||||
itEachAbsentCollectionValue<TreeInputNodeData>((absentValue) => {
|
itEachAbsentCollectionValue<TreeInputNodeData>((absentValue) => {
|
||||||
// arrange
|
// arrange
|
||||||
const expectedError = 'missing data';
|
const expectedError = 'missing data';
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { ScriptDiagnosticsCollectorStub } from '../../../shared/Stubs/ScriptDiag
|
|||||||
|
|
||||||
describe('IpcRegistration', () => {
|
describe('IpcRegistration', () => {
|
||||||
describe('registerAllIpcChannels', () => {
|
describe('registerAllIpcChannels', () => {
|
||||||
it('registers all defined IPC channels', () => {
|
describe('registers all defined IPC channels', () => {
|
||||||
Object.entries(IpcChannelDefinitions).forEach(([key, expectedChannel]) => {
|
Object.entries(IpcChannelDefinitions).forEach(([key, expectedChannel]) => {
|
||||||
it(key, () => {
|
it(key, () => {
|
||||||
// arrange
|
// arrange
|
||||||
|
|||||||
Reference in New Issue
Block a user