mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge studio into feat/fix-11734-c
This commit is contained in:
commit
8aed0bc8ab
1488 files changed
+85348
-6992
No files matched your search
@@ -10,6 +10,7 @@
|
||||
|
||||
# User-facing guides and references.
|
||||
/docs/ @SivanCola @esengine
|
||||
/docs/THEME_AUTHOR_GUIDE.md @esengine @SivanCola
|
||||
/sdk/ @SivanCola @esengine
|
||||
/README.md @SivanCola @esengine
|
||||
/README.zh-CN.md @SivanCola @esengine
|
||||
@@ -36,6 +37,7 @@
|
||||
/.github/workflows/studio-certum-signing-smoke.yml @esengine @SivanCola
|
||||
/.github/actions/setup-certum/ @esengine @SivanCola
|
||||
/cmd/signpath-contract/ @esengine @SivanCola
|
||||
/scripts/install-smoke.ps1 @esengine @SivanCola
|
||||
/scripts/sign-certum.ps1 @esengine @SivanCola
|
||||
/scripts/verify-windows-authenticode.ps1 @esengine @SivanCola
|
||||
/scripts/verify-windows-package.ps1 @esengine @SivanCola
|
||||
|
||||
@@ -8,6 +8,34 @@ only, on `main-v2`.
|
||||
|
||||
-
|
||||
|
||||
## Cause
|
||||
|
||||
-
|
||||
|
||||
## Blast radius
|
||||
|
||||
-
|
||||
|
||||
## Neighbouring behaviours tested
|
||||
|
||||
-
|
||||
|
||||
## Why this layer
|
||||
|
||||
-
|
||||
|
||||
## UI changes
|
||||
|
||||
<!--
|
||||
Delete this section if the PR touches nothing under `desktop/frontend-next/src`
|
||||
or the website pages.
|
||||
-->
|
||||
|
||||
- Screenshots, before and after: light and dark, a wide window and a phone-width one (about 390 px). Attach them to this description.
|
||||
- Entries moved, hidden or removed: none / list each one.
|
||||
- Hiding or removing a visible control needs a linked issue and a release-notes line naming it. Do not retire UI with `display: none !important` or a feature flag.
|
||||
- If a row of `docs/STUDIO_PARITY.md` changes, update it here.
|
||||
|
||||
## Issues
|
||||
|
||||
<!--
|
||||
@@ -26,3 +54,7 @@ the source-to-target contract here: easing syntax, time units, callbacks,
|
||||
cancellation, reduced-motion behavior, and failure fallback. Verification must
|
||||
assert that the target API was actually called; a mock that silently skips it
|
||||
does not count.
|
||||
|
||||
## Disclosure
|
||||
|
||||
- [ ] AI assistance was used for this change (optional; for transparency only, it does not change how the change is reviewed)
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 145.55 113.29">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #ffffff;
|
||||
stroke: #ffffff;
|
||||
stroke-miterlimit: 10;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="_图层_1-2" data-name="图层 1">
|
||||
<g>
|
||||
<path class="cls-1" d="M144.68,38.49l-.06-.23c-.88-3.28-2.5-5.94-4.58-8.06.14,5.65-2.96,11.02-6.22,16.66l-.39.68c-2.26,3.91-4.66,7.94-6.57,11.1l-.86,1.38c-3.36,5.44-6.27,10.14-12.18,14.18-8.34,5.87-18.2,5.81-26.92,5.76-2.81-.02-5.48-.03-7.95.14l-.35.02c-3.22.06-5.96,1.57-8.17,4.49l-.14.18c-.86,1.06-1.7,2.26-2.58,3.54-3.43,4.92-7.69,11.04-16.17,12.42-4.37.86-9.98.84-14.94.83h-1.95c-.52,0-1.06,0-1.61,0-6.95,0-16.08-.89-21.94-6.55-.15-.15-.3-.3-.44-.45.5,4.73,2.33,8.64,5.44,11.65,5.86,5.66,14.98,6.55,21.94,6.55.55,0,1.09,0,1.61-.01h1.93c4.96.02,10.58.04,14.96-.83,8.48-1.37,12.74-7.5,16.17-12.42.88-1.27,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.43,8.17-4.49l.35-.02c2.47-.17,5.13-.16,7.95-.14,8.72.05,18.58.11,26.91-5.76,5.92-4.03,8.82-8.73,12.19-14.17l.86-1.39c1.91-3.15,4.3-7.18,6.57-11.09l.39-.69c3.81-6.6,7.41-12.84,5.86-19.57ZM120.9,23.02c-.28,0-.56,0-.83,0-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.41.69-.77,1.3-1.09,1.84l-.49.84c-1.49,2.53-3.23,5.49-4.21,8.95,3.98,1.9,8.52,2.65,12.71,2.91.27-.77.64-1.56,1.07-2.38.48-.94,1.05-1.9,1.63-2.89l.49-.84c.96-1.62,2.38-3.99,4.05-6.78,3.82-6.36,8.98-14.88,13.19-21.55l.07-.11c5.02-8.31,9.2-13.45,16.91-16.81l.11-.05c6.57-2.7,10.54-2.74,18.43-2.81.87-.01,1.78-.02,2.69-.03l1.99-.02c9.17-.06,20.13-.14,29.01-.07,2.26.01,4.39.04,6.32.08h.12s.11,0,.11,0c1.25-.01,2.91.11,4.61.46,1.03.22,2.08.52,3.05.93.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82-4.08-1.92-8.7-2.76-12.89-2.82Z"/>
|
||||
<path class="cls-1" d="M115.07,11.81c-9.7-.19-24.07-.09-35.62-.01h-1.99c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.13,9.71-14.19,23.21-17.41,28.63l-.5.84c-2.08,3.55-4.68,7.97-4.94,13.39v.2s0,.21,0,.21c.01.81.06,1.61.15,2.38.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-5.18-5.29-13.22-7.25-19.97-7.19ZM122.56,40.37l-.39.67c-2.21,3.81-4.55,7.77-6.39,10.79l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.95,3.45-19.38,3.41-2.88-.02-5.87-.04-8.79.16-7.1.19-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.28.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-2.24-.48-3.87-1.22-4.89-2.2-.66-.64-1.54-1.81-1.63-4.65.11-1.35.71-2.82,1.52-4.35.48-.94,1.05-1.9,1.63-2.89l.49-.84c3.17-5.33,11.19-18.75,17.24-28.33l.07-.11c5.02-8.32,9.2-13.45,16.92-16.81l.1-.05c6.57-2.7,10.54-2.74,18.43-2.82.87,0,1.78,0,2.69-.03h1.94c11.52-.09,25.86-.19,35.38,0h.23c1.25-.01,2.92.11,4.61.46,3.15.66,6.4,2.12,7.27,5.02.2,1.2-.89,3.62-2.27,6.18-.7,1.31-1.48,2.65-2.2,3.9ZM120.07,23.01c-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.28,0-.56,0-.83,0Z"/>
|
||||
<path class="cls-1" d="M134.68,16.09l-.06-.23c-3.07-11.45-15.08-15.33-24.55-15.25-9.68-.19-24.03-.09-35.57-.01h-2.04c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.14,9.73-14.2,23.22-17.41,28.62l-.49.85c-2.09,3.55-4.69,7.96-4.95,13.39v.2s0,.21,0,.21c.09,5.57,1.82,10.13,5.15,13.58.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-.02-.96-.14-1.93-.36-2.91ZM119.76,25.27c-.7,1.31-1.48,2.65-2.2,3.9l-.39.67c-1.19,2.04-2.41,4.13-3.57,6.09-1.01,1.7-1.97,3.31-2.82,4.7l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.96,3.45-19.38,3.41-2.89-.02-5.87-.04-8.79.16-7.1.18-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.27.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-1.16-.25-2.16-.57-2.99-.96-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.34.72-.73,1.48-1.14,2.25Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 145.55 113.29">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #111111;
|
||||
stroke: #111111;
|
||||
stroke-miterlimit: 10;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="_图层_1-2" data-name="图层 1">
|
||||
<g>
|
||||
<path class="cls-1" d="M144.68,38.49l-.06-.23c-.88-3.28-2.5-5.94-4.58-8.06.14,5.65-2.96,11.02-6.22,16.66l-.39.68c-2.26,3.91-4.66,7.94-6.57,11.1l-.86,1.38c-3.36,5.44-6.27,10.14-12.18,14.18-8.34,5.87-18.2,5.81-26.92,5.76-2.81-.02-5.48-.03-7.95.14l-.35.02c-3.22.06-5.96,1.57-8.17,4.49l-.14.18c-.86,1.06-1.7,2.26-2.58,3.54-3.43,4.92-7.69,11.04-16.17,12.42-4.37.86-9.98.84-14.94.83h-1.95c-.52,0-1.06,0-1.61,0-6.95,0-16.08-.89-21.94-6.55-.15-.15-.3-.3-.44-.45.5,4.73,2.33,8.64,5.44,11.65,5.86,5.66,14.98,6.55,21.94,6.55.55,0,1.09,0,1.61-.01h1.93c4.96.02,10.58.04,14.96-.83,8.48-1.37,12.74-7.5,16.17-12.42.88-1.27,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.43,8.17-4.49l.35-.02c2.47-.17,5.13-.16,7.95-.14,8.72.05,18.58.11,26.91-5.76,5.92-4.03,8.82-8.73,12.19-14.17l.86-1.39c1.91-3.15,4.3-7.18,6.57-11.09l.39-.69c3.81-6.6,7.41-12.84,5.86-19.57ZM120.9,23.02c-.28,0-.56,0-.83,0-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.41.69-.77,1.3-1.09,1.84l-.49.84c-1.49,2.53-3.23,5.49-4.21,8.95,3.98,1.9,8.52,2.65,12.71,2.91.27-.77.64-1.56,1.07-2.38.48-.94,1.05-1.9,1.63-2.89l.49-.84c.96-1.62,2.38-3.99,4.05-6.78,3.82-6.36,8.98-14.88,13.19-21.55l.07-.11c5.02-8.31,9.2-13.45,16.91-16.81l.11-.05c6.57-2.7,10.54-2.74,18.43-2.81.87-.01,1.78-.02,2.69-.03l1.99-.02c9.17-.06,20.13-.14,29.01-.07,2.26.01,4.39.04,6.32.08h.12s.11,0,.11,0c1.25-.01,2.91.11,4.61.46,1.03.22,2.08.52,3.05.93.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82-4.08-1.92-8.7-2.76-12.89-2.82Z"/>
|
||||
<path class="cls-1" d="M115.07,11.81c-9.7-.19-24.07-.09-35.62-.01h-1.99c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.13,9.71-14.19,23.21-17.41,28.63l-.5.84c-2.08,3.55-4.68,7.97-4.94,13.39v.2s0,.21,0,.21c.01.81.06,1.61.15,2.38.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-5.18-5.29-13.22-7.25-19.97-7.19ZM122.56,40.37l-.39.67c-2.21,3.81-4.55,7.77-6.39,10.79l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.95,3.45-19.38,3.41-2.88-.02-5.87-.04-8.79.16-7.1.19-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.28.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-2.24-.48-3.87-1.22-4.89-2.2-.66-.64-1.54-1.81-1.63-4.65.11-1.35.71-2.82,1.52-4.35.48-.94,1.05-1.9,1.63-2.89l.49-.84c3.17-5.33,11.19-18.75,17.24-28.33l.07-.11c5.02-8.32,9.2-13.45,16.92-16.81l.1-.05c6.57-2.7,10.54-2.74,18.43-2.82.87,0,1.78,0,2.69-.03h1.94c11.52-.09,25.86-.19,35.38,0h.23c1.25-.01,2.92.11,4.61.46,3.15.66,6.4,2.12,7.27,5.02.2,1.2-.89,3.62-2.27,6.18-.7,1.31-1.48,2.65-2.2,3.9ZM120.07,23.01c-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.28,0-.56,0-.83,0Z"/>
|
||||
<path class="cls-1" d="M134.68,16.09l-.06-.23c-3.07-11.45-15.08-15.33-24.55-15.25-9.68-.19-24.03-.09-35.57-.01h-2.04c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.14,9.73-14.2,23.22-17.41,28.62l-.49.85c-2.09,3.55-4.69,7.96-4.95,13.39v.2s0,.21,0,.21c.09,5.57,1.82,10.13,5.15,13.58.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-.02-.96-.14-1.93-.36-2.91ZM119.76,25.27c-.7,1.31-1.48,2.65-2.2,3.9l-.39.67c-1.19,2.04-2.41,4.13-3.57,6.09-1.01,1.7-1.97,3.31-2.82,4.7l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.96,3.45-19.38,3.41-2.89-.02-5.87-.04-8.79.16-7.1.18-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.27.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-1.16-.25-2.16-.57-2.99-.96-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.34.72-.73,1.48-1.14,2.25Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
@@ -62,7 +62,7 @@ jobs:
|
||||
# both paths must trigger the sdk job.
|
||||
if echo "$files" | grep -qE '^(sdk/|internal/ext/extension/)'; then sdk=true; fi
|
||||
# The release-note tests render every committed Studio note; npm/ holds the publish tests.
|
||||
if echo "$files" | grep -qE '^(scripts/|npm/|release-notes/studio/)'; then scripts=true; fi
|
||||
if echo "$files" | grep -qE '^(scripts/|npm/|release-notes/studio/|\.github/workflows/release-studio\.yml$)'; then scripts=true; fi
|
||||
fi
|
||||
fi
|
||||
{ echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; echo "scripts=$scripts"; } >> "$GITHUB_OUTPUT"
|
||||
@@ -80,7 +80,11 @@ jobs:
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
- run: node --test scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- run: node --test scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
|
||||
|
||||
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
|
||||
# A matrix job skipped at job level reports no per-leg checks at all, so
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Deploy ops events relay
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-ops-events
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.ref == 'refs/heads/platform'
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: workers/ops-events/package-lock.json
|
||||
- name: Validate
|
||||
working-directory: workers/ops-events
|
||||
run: |
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
- name: Deploy
|
||||
working-directory: workers/ops-events
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npx wrangler deploy
|
||||
- name: Sync secrets
|
||||
working-directory: workers/ops-events
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
GITHUB_WEBHOOK_SECRET: ${{ secrets.OPS_GITHUB_WEBHOOK_SECRET }}
|
||||
OPS_LISTEN_TOKEN: ${{ secrets.OPS_LISTEN_TOKEN }}
|
||||
OPS_EMIT_TOKEN: ${{ secrets.OPS_EMIT_TOKEN }}
|
||||
run: |
|
||||
node -e 'process.stdout.write(JSON.stringify({GITHUB_WEBHOOK_SECRET:process.env.GITHUB_WEBHOOK_SECRET||null,OPS_LISTEN_TOKEN:process.env.OPS_LISTEN_TOKEN||null,OPS_EMIT_TOKEN:process.env.OPS_EMIT_TOKEN||null}))' | npx wrangler secret bulk
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
@@ -5,10 +5,12 @@ run-name: Release Studio ${{ inputs.tag || github.ref_name }}
|
||||
# (studio-v*), its own environment gate, and its own rollback catalog at
|
||||
# dl.reasonix.io/studio/versions.json.
|
||||
#
|
||||
# What it must never touch: the repository-wide "latest" release (every Studio
|
||||
# release is a prerelease) and the root dl.reasonix.io/versions.json, which is
|
||||
# the desktop line's catalog. Studio artifacts land under the tag prefix, which
|
||||
# cannot collide because the tag name itself differs.
|
||||
# What it must never touch: the root dl.reasonix.io/versions.json, which is the
|
||||
# desktop line's catalog. Studio artifacts land under the tag prefix, which
|
||||
# cannot collide because the tag name itself differs. The highest stable tag
|
||||
# is the repository's GitHub latest and a candidate is a prerelease; the GitHub
|
||||
# release carries no root latest.json, because the 1.x updaters' last-resort
|
||||
# URL is releases/latest/download/latest.json and must keep answering 404.
|
||||
#
|
||||
# Windows artifacts are Authenticode-signed with a Certum SimplySign cloud
|
||||
# certificate in two stages: every PE file in the application tree, then the
|
||||
@@ -74,8 +76,7 @@ jobs:
|
||||
fi
|
||||
version="${tag#studio-}"
|
||||
# Semver's prerelease segment, group 4, read from the grammar rather
|
||||
# than the spelling. Every Studio release carries GitHub's prerelease
|
||||
# flag, so the tag-level distinction needs a name of its own.
|
||||
# than the spelling.
|
||||
candidate=false
|
||||
if [[ "${version#v}" =~ $semver ]] && [ -n "${BASH_REMATCH[4]}" ]; then
|
||||
candidate=true
|
||||
@@ -148,10 +149,21 @@ jobs:
|
||||
# vocabularies disagree (x64 / amd64) and a reviewer has to see both.
|
||||
# Absent means the runner's own, which is what Windows and Linux build.
|
||||
# artifact is the exact name publish downloads this leg's packages by.
|
||||
# goarch and unpacked belong to the Windows legs: the release
|
||||
# architecture the package is named for, and the directory
|
||||
# electron-builder leaves its bundle in (win-unpacked for x64 alone).
|
||||
include:
|
||||
- platform: windows/amd64
|
||||
runner: windows-latest
|
||||
goarch: amd64
|
||||
unpacked: win-unpacked
|
||||
artifact: studio-build-windows-amd64
|
||||
- platform: windows/arm64
|
||||
runner: windows-11-arm
|
||||
ebarch: arm64
|
||||
goarch: arm64
|
||||
unpacked: win-arm64-unpacked
|
||||
artifact: studio-build-windows-arm64
|
||||
- platform: darwin/amd64
|
||||
runner: macos-latest
|
||||
ebarch: x64
|
||||
@@ -266,6 +278,7 @@ jobs:
|
||||
# kernel on a remote machine, and those routes name a release by it.
|
||||
env:
|
||||
REASONIX_VERSION: ${{ needs.resolve.outputs.version }}
|
||||
REASONIX_GOARCH: ${{ matrix.goarch }}
|
||||
run: pnpm install --frozen-lockfile && pnpm run build:host
|
||||
|
||||
# The helper the Linux self-update runs under pkexec, staged where the
|
||||
@@ -288,6 +301,14 @@ jobs:
|
||||
if [ -n "${{ matrix.ebarch }}" ]; then arch="--${{ matrix.ebarch }}"; fi
|
||||
pnpm exec electron-builder --dir $arch -c.extraMetadata.version="${version#v}"
|
||||
|
||||
# An emulated x64 program runs on ARM64 Windows, so a bundle that starts
|
||||
# proves nothing about its architecture: every PE image in it has to be
|
||||
# built for the architecture the package is named for.
|
||||
- name: Every PE image is built for the package's architecture
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: node desktop/electron/packaging/pe.js "dist/${{ matrix.unpacked }}" "${{ matrix.goarch }}"
|
||||
|
||||
# Notarization happens to the bundle, so it sits between the two packaging
|
||||
# steps the way Authenticode does on Windows. Stapling here is what lets a
|
||||
# first launch work offline; a ticket Apple has but the bundle does not is
|
||||
@@ -344,8 +365,8 @@ jobs:
|
||||
if: runner.os == 'Windows' && env.SIGN_WINDOWS == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: unsigned-studio-windows-bundle
|
||||
path: dist/win-unpacked
|
||||
name: unsigned-studio-windows-${{ matrix.goarch }}-bundle
|
||||
path: dist/${{ matrix.unpacked }}
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
@@ -356,17 +377,24 @@ jobs:
|
||||
if: runner.os == 'Windows' && env.SIGN_WINDOWS != 'true'
|
||||
working-directory: desktop/electron
|
||||
shell: bash
|
||||
env:
|
||||
# The arm64 installer has to be packed with the BCJ filter: 7-Zip
|
||||
# applies an ARM64 filter to ARM64 executables by default, the NSIS
|
||||
# extractor cannot decode it, and the installer then exits 0 with
|
||||
# every executable missing.
|
||||
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.goarch == 'arm64' && 'BCJ' || '' }}
|
||||
run: |
|
||||
version="${{ needs.resolve.outputs.version }}"
|
||||
arch="--${{ matrix.ebarch || 'x64' }}"
|
||||
rm -rf ../../zip-src ../../dist-zip
|
||||
cp -r ../../dist/win-unpacked ../../zip-src
|
||||
pnpm exec electron-builder --prepackaged ../../zip-src --win zip \
|
||||
cp -r ../../dist/${{ matrix.unpacked }} ../../zip-src
|
||||
pnpm exec electron-builder --prepackaged ../../zip-src --win zip $arch \
|
||||
-c.extraMetadata.version="${version#v}" -c.directories.output=../../dist-zip &
|
||||
zip_pid=$!
|
||||
pnpm exec electron-builder --prepackaged ../../dist/win-unpacked --win nsis \
|
||||
pnpm exec electron-builder --prepackaged ../../dist/${{ matrix.unpacked }} --win nsis $arch \
|
||||
-c.extraMetadata.version="${version#v}"
|
||||
wait "$zip_pid"
|
||||
mv ../../dist-zip/ReasonixStudio-windows-amd64.zip ../../dist/
|
||||
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.goarch }}.zip ../../dist/
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: runner.os != 'Windows' || env.SIGN_WINDOWS != 'true'
|
||||
@@ -382,22 +410,36 @@ jobs:
|
||||
# package check run between them on their own runners with no secrets. Both
|
||||
# signing jobs share one group with the Studio smoke test, so no two runs
|
||||
# drive the same SimplySign account at once.
|
||||
#
|
||||
# Each signing stage is a matrix over the Windows architectures with one tree
|
||||
# per job. max-parallel: 1 keeps the legs strictly serial, which the shared
|
||||
# certum-signing group needs: a group holds one running and one waiting job,
|
||||
# so two legs started together would have the waiting one cancelled by the
|
||||
# next arrival. A failed leg reruns alone; the others' results are kept.
|
||||
windows-sign-payload:
|
||||
name: sign the Windows PE files (Certum)
|
||||
name: sign the Windows PE files (Certum, ${{ matrix.arch }})
|
||||
needs: [resolve, signing-contract, build]
|
||||
if: vars.STUDIO_SIGNING_ENABLED == 'true'
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
environment: studio-release
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: certum-signing
|
||||
cancel-in-progress: false
|
||||
# One output per architecture, because matrix legs share a job's output map:
|
||||
# a leg writes only its own and an empty value never replaces another's.
|
||||
# Recorded here because no later job can write this job's outputs: the
|
||||
# installer signing job refuses a payload artifact that no longer hashes to it.
|
||||
outputs:
|
||||
payload-digest: ${{ steps.verify.outputs.digest }}
|
||||
payload-digest-amd64: ${{ matrix.arch == 'amd64' && steps.verify.outputs.digest || '' }}
|
||||
payload-digest-arm64: ${{ matrix.arch == 'arm64' && steps.verify.outputs.digest || '' }}
|
||||
steps:
|
||||
# Only the workflow's own commit is checked out: these jobs run the signer
|
||||
# and its checks, never anything the release tag builds.
|
||||
@@ -438,7 +480,7 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: unsigned-studio-windows-bundle
|
||||
name: unsigned-studio-windows-${{ matrix.arch }}-bundle
|
||||
path: signing/app
|
||||
|
||||
# Stage one: every PE file in the application tree that Microsoft has
|
||||
@@ -485,6 +527,7 @@ jobs:
|
||||
$ErrorActionPreference = 'Stop'
|
||||
./scripts/verify-windows-authenticode.ps1 `
|
||||
-PayloadDirectory signing/app `
|
||||
-Architecture ${{ matrix.arch }} `
|
||||
-DigestPath "$env:RUNNER_TEMP/payload-digest" `
|
||||
-ExpectedThumbprint $env:CERTUM_KEY_ID `
|
||||
-ExpectedSubject $env:EXPECTED_SUBJECT
|
||||
@@ -492,7 +535,7 @@ jobs:
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: signed-studio-windows-payload
|
||||
name: signed-studio-windows-payload-${{ matrix.arch }}
|
||||
path: signing/app
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
@@ -500,10 +543,20 @@ jobs:
|
||||
# No environment and no secrets: electron-builder and its dependencies run
|
||||
# here, on a runner that never held a signing session.
|
||||
windows-package:
|
||||
name: package Windows from the signed tree
|
||||
name: package Windows from the signed tree (${{ matrix.arch }})
|
||||
needs: [resolve, windows-sign-payload]
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
ebarch: x64
|
||||
unpacked: win-unpacked
|
||||
- arch: arm64
|
||||
ebarch: arm64
|
||||
unpacked: win-arm64-unpacked
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -536,8 +589,8 @@ jobs:
|
||||
# The signed payload is the whole bundle, so it is packaged as it came.
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: signed-studio-windows-payload
|
||||
path: dist/win-unpacked
|
||||
name: signed-studio-windows-payload-${{ matrix.arch }}
|
||||
path: dist/${{ matrix.unpacked }}
|
||||
|
||||
# Both packages come from the signed tree, so the archive carries the
|
||||
# signed files too rather than a second unsigned copy. The zip is packed
|
||||
@@ -545,24 +598,27 @@ jobs:
|
||||
- name: Package Windows from the signed bundle
|
||||
working-directory: desktop/electron
|
||||
shell: bash
|
||||
env:
|
||||
# See the build job: the arm64 installer must be packed with BCJ.
|
||||
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.arch == 'arm64' && 'BCJ' || '' }}
|
||||
run: |
|
||||
version="${{ needs.resolve.outputs.version }}"
|
||||
rm -rf ../../zip-src ../../dist-zip
|
||||
cp -r ../../dist/win-unpacked ../../zip-src
|
||||
pnpm exec electron-builder --prepackaged ../../zip-src --win zip \
|
||||
cp -r ../../dist/${{ matrix.unpacked }} ../../zip-src
|
||||
pnpm exec electron-builder --prepackaged ../../zip-src --win zip --${{ matrix.ebarch }} \
|
||||
-c.extraMetadata.version="${version#v}" -c.directories.output=../../dist-zip &
|
||||
zip_pid=$!
|
||||
pnpm exec electron-builder --prepackaged ../../dist/win-unpacked --win nsis \
|
||||
pnpm exec electron-builder --prepackaged ../../dist/${{ matrix.unpacked }} --win nsis --${{ matrix.ebarch }} \
|
||||
-c.extraMetadata.version="${version#v}"
|
||||
wait "$zip_pid"
|
||||
mv ../../dist-zip/ReasonixStudio-windows-amd64.zip ../../dist/
|
||||
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.arch }}.zip ../../dist/
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: packaged-studio-windows
|
||||
name: packaged-studio-windows-${{ matrix.arch }}
|
||||
path: |
|
||||
dist/ReasonixStudio-windows-amd64-installer.exe
|
||||
dist/ReasonixStudio-windows-amd64.zip
|
||||
dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe
|
||||
dist/ReasonixStudio-windows-${{ matrix.arch }}.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
@@ -571,15 +627,21 @@ jobs:
|
||||
# secrets, never in a job that holds the Certum credentials. Its outputs are
|
||||
# the hashes of the two files it checked, which the signing job requires.
|
||||
windows-verify-package:
|
||||
name: check the Windows packages against the signed tree
|
||||
name: check the Windows packages against the signed tree (${{ matrix.arch }})
|
||||
needs: [resolve, windows-sign-payload, windows-package]
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
installer-sha256: ${{ steps.check.outputs.installer-sha256 }}
|
||||
archive-sha256: ${{ steps.check.outputs.archive-sha256 }}
|
||||
installer-sha256-amd64: ${{ matrix.arch == 'amd64' && steps.check.outputs.installer-sha256 || '' }}
|
||||
installer-sha256-arm64: ${{ matrix.arch == 'arm64' && steps.check.outputs.installer-sha256 || '' }}
|
||||
archive-sha256-amd64: ${{ matrix.arch == 'amd64' && steps.check.outputs.archive-sha256 || '' }}
|
||||
archive-sha256-arm64: ${{ matrix.arch == 'arm64' && steps.check.outputs.archive-sha256 || '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
@@ -588,12 +650,12 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: signed-studio-windows-payload
|
||||
name: signed-studio-windows-payload-${{ matrix.arch }}
|
||||
path: payload
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: packaged-studio-windows
|
||||
name: packaged-studio-windows-${{ matrix.arch }}
|
||||
path: dist
|
||||
|
||||
# 7-Zip is the runner image's, not the tag's.
|
||||
@@ -601,20 +663,26 @@ jobs:
|
||||
id: check
|
||||
shell: pwsh
|
||||
env:
|
||||
PAYLOAD_DIGEST: ${{ needs.windows-sign-payload.outputs.payload-digest }}
|
||||
PAYLOAD_DIGEST: ${{ needs.windows-sign-payload.outputs[format('payload-digest-{0}', matrix.arch)] }}
|
||||
run: |
|
||||
./scripts/verify-windows-package.ps1 `
|
||||
-PayloadDirectory payload `
|
||||
-ExpectedPayloadDigest $env:PAYLOAD_DIGEST `
|
||||
-InstallerPath "dist/ReasonixStudio-windows-amd64-installer.exe" `
|
||||
-PortableArchivePath "dist/ReasonixStudio-windows-amd64.zip" `
|
||||
-OutputPath $env:GITHUB_OUTPUT
|
||||
-InstallerPath "dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe" `
|
||||
-PortableArchivePath "dist/ReasonixStudio-windows-${{ matrix.arch }}.zip" `
|
||||
-OutputPath $env:GITHUB_OUTPUT `
|
||||
-Architecture ${{ matrix.arch }}
|
||||
|
||||
windows-sign-installer:
|
||||
name: sign the Windows installer (Certum)
|
||||
name: sign the Windows installer (Certum, ${{ matrix.arch }})
|
||||
needs: [resolve, windows-package, windows-verify-package]
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
environment: studio-release
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -623,7 +691,8 @@ jobs:
|
||||
cancel-in-progress: false
|
||||
# The signed installer's hash, which publish requires of what it downloads.
|
||||
outputs:
|
||||
installer-sha256: ${{ steps.signed.outputs.installer-sha256 }}
|
||||
installer-sha256-amd64: ${{ matrix.arch == 'amd64' && steps.signed.outputs.installer-sha256 || '' }}
|
||||
installer-sha256-arm64: ${{ matrix.arch == 'arm64' && steps.signed.outputs.installer-sha256 || '' }}
|
||||
steps:
|
||||
# Only the workflow's own commit is checked out: these jobs run the signer
|
||||
# and its checks, never anything the release tag builds.
|
||||
@@ -664,7 +733,7 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: packaged-studio-windows
|
||||
name: packaged-studio-windows-${{ matrix.arch }}
|
||||
path: dist
|
||||
|
||||
# Only the two files windows-verify-package checked, byte for byte. This
|
||||
@@ -673,13 +742,13 @@ jobs:
|
||||
- name: Require the checked packages
|
||||
shell: pwsh
|
||||
env:
|
||||
INSTALLER_SHA256: ${{ needs.windows-verify-package.outputs.installer-sha256 }}
|
||||
ARCHIVE_SHA256: ${{ needs.windows-verify-package.outputs.archive-sha256 }}
|
||||
INSTALLER_SHA256: ${{ needs.windows-verify-package.outputs[format('installer-sha256-{0}', matrix.arch)] }}
|
||||
ARCHIVE_SHA256: ${{ needs.windows-verify-package.outputs[format('archive-sha256-{0}', matrix.arch)] }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$expected = [ordered]@{
|
||||
'ReasonixStudio-windows-amd64-installer.exe' = $env:INSTALLER_SHA256
|
||||
'ReasonixStudio-windows-amd64.zip' = $env:ARCHIVE_SHA256
|
||||
'ReasonixStudio-windows-${{ matrix.arch }}-installer.exe' = $env:INSTALLER_SHA256
|
||||
'ReasonixStudio-windows-${{ matrix.arch }}.zip' = $env:ARCHIVE_SHA256
|
||||
}
|
||||
$present = @(Get-ChildItem -LiteralPath dist -Recurse -Force | ForEach-Object { $_.Name })
|
||||
if (Compare-Object -CaseSensitive @($expected.Keys) $present) { throw "dist holds $($present -join ', ')" }
|
||||
@@ -701,7 +770,7 @@ jobs:
|
||||
shell: pwsh
|
||||
env:
|
||||
CERTUM_KEY_ID: ${{ secrets.CERTUM_KEY_ID }}
|
||||
run: ./scripts/sign-certum.ps1 -FilePath dist/ReasonixStudio-windows-amd64-installer.exe
|
||||
run: ./scripts/sign-certum.ps1 -FilePath dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe
|
||||
|
||||
# The session signs for any process on the runner while it is up.
|
||||
- name: Disconnect from Certum
|
||||
@@ -717,7 +786,7 @@ jobs:
|
||||
EXPECTED_SUBJECT: ${{ vars.STUDIO_SIGNING_SUBJECT }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$installer = 'dist/ReasonixStudio-windows-amd64-installer.exe'
|
||||
$installer = 'dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe'
|
||||
./scripts/verify-windows-authenticode.ps1 `
|
||||
-FilePath $installer `
|
||||
-ExpectedThumbprint $env:CERTUM_KEY_ID `
|
||||
@@ -727,11 +796,60 @@ jobs:
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: studio-windows-signed
|
||||
name: studio-windows-signed-${{ matrix.arch }}
|
||||
path: dist/ReasonixStudio-*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
# Installs each Windows installer where it is meant to run (the same script
|
||||
# the Studio workflow runs on pull requests, against the unsigned installer).
|
||||
# The package check unpacks with the runner's modern 7-Zip, which reads
|
||||
# archives the NSIS extractor cannot, so only running the installer shows that
|
||||
# it installs every executable. No environment and no secrets.
|
||||
windows-install-smoke:
|
||||
name: install the Windows package (${{ matrix.arch }})
|
||||
needs: [resolve, build, windows-sign-installer]
|
||||
if: >-
|
||||
${{ !cancelled() && needs.resolve.result == 'success' && needs.build.result == 'success' &&
|
||||
(needs.windows-sign-installer.result == 'success' ||
|
||||
(vars.STUDIO_SIGNING_ENABLED != 'true' && needs.windows-sign-installer.result == 'skipped')) }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
runner: windows-latest
|
||||
- arch: arm64
|
||||
runner: windows-11-arm
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
with:
|
||||
name: studio-windows-signed-${{ matrix.arch }}
|
||||
path: dist
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: needs.windows-sign-installer.result != 'success'
|
||||
with:
|
||||
name: studio-build-windows-${{ matrix.arch }}
|
||||
path: dist
|
||||
|
||||
- name: Install, check and uninstall
|
||||
shell: pwsh
|
||||
run: |
|
||||
./scripts/install-smoke.ps1 `
|
||||
-Installer "dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe" `
|
||||
-Architecture ${{ matrix.arch }}
|
||||
|
||||
# The kernel a Studio installs on another machine. This line publishes it
|
||||
# under its own tag, because the install routes name a release by the version
|
||||
# the local build reports — and a line that ships no archive leaves every one
|
||||
@@ -757,14 +875,18 @@ jobs:
|
||||
- name: Cross-compile and archive
|
||||
env:
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
SHA: ${{ needs.resolve.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
built="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
pkg=reasonix/internal/tools/productdocs
|
||||
for target in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do
|
||||
os="${target%/*}"; arch="${target#*/}"; ext=""
|
||||
[ "$os" = windows ] && ext=".exe"
|
||||
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath \
|
||||
-ldflags "-s -w -X main.version=$VERSION" -o "reasonix$ext" ./cmd/reasonix
|
||||
-ldflags "-s -w -X main.version=$VERSION -X main.gitCommit=${SHA:0:12} -X main.buildTimeUTC=$built -X $pkg.linkedVersion=$VERSION -X $pkg.linkedRevision=$SHA" \
|
||||
-o "reasonix$ext" ./cmd/reasonix
|
||||
if [ "$os" = windows ]; then
|
||||
zip -qj "dist/reasonix-$os-$arch.zip" "reasonix$ext"
|
||||
else
|
||||
@@ -798,12 +920,13 @@ jobs:
|
||||
|
||||
publish:
|
||||
name: sign, publish and mirror
|
||||
needs: [resolve, build, cli, windows-sign-payload, windows-package, windows-verify-package, windows-sign-installer]
|
||||
needs: [resolve, build, cli, windows-sign-payload, windows-package, windows-verify-package, windows-sign-installer, windows-install-smoke]
|
||||
# The Windows signing jobs may be skipped only while the switch is off; with
|
||||
# it on, any of them failing, cancelled or absent blocks the release.
|
||||
if: >-
|
||||
${{ !cancelled() && needs.resolve.result == 'success' && needs.build.result == 'success' &&
|
||||
needs.cli.result == 'success' && ((needs.windows-sign-payload.result == 'success' &&
|
||||
needs.cli.result == 'success' && needs.windows-install-smoke.result == 'success' &&
|
||||
((needs.windows-sign-payload.result == 'success' &&
|
||||
needs.windows-package.result == 'success' && needs.windows-verify-package.result == 'success' &&
|
||||
needs.windows-sign-installer.result == 'success') ||
|
||||
(vars.STUDIO_SIGNING_ENABLED != 'true' && needs.windows-sign-payload.result == 'skipped' &&
|
||||
@@ -865,43 +988,63 @@ jobs:
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
with:
|
||||
name: studio-windows-signed
|
||||
path: staging/studio-windows
|
||||
name: studio-windows-signed-amd64
|
||||
path: staging/studio-windows-amd64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
with:
|
||||
name: studio-windows-signed-arm64
|
||||
path: staging/studio-windows-arm64
|
||||
|
||||
# Any job can upload an artifact, so the signed Windows packages are
|
||||
# taken only if they are the bytes the signing jobs recorded: the
|
||||
# installer as signed, the zip as windows-verify-package checked it.
|
||||
# taken only if they are the bytes the signing jobs recorded: each
|
||||
# installer as signed, each zip as windows-verify-package checked it.
|
||||
- name: Require the recorded Windows packages
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
env:
|
||||
INSTALLER_SHA256: ${{ needs.windows-sign-installer.outputs.installer-sha256 }}
|
||||
ARCHIVE_SHA256: ${{ needs.windows-verify-package.outputs.archive-sha256 }}
|
||||
INSTALLER_SHA256_AMD64: ${{ needs.windows-sign-installer.outputs[format('installer-sha256-{0}', 'amd64')] }}
|
||||
ARCHIVE_SHA256_AMD64: ${{ needs.windows-verify-package.outputs[format('archive-sha256-{0}', 'amd64')] }}
|
||||
INSTALLER_SHA256_ARM64: ${{ needs.windows-sign-installer.outputs[format('installer-sha256-{0}', 'arm64')] }}
|
||||
ARCHIVE_SHA256_ARM64: ${{ needs.windows-verify-package.outputs[format('archive-sha256-{0}', 'arm64')] }}
|
||||
run: |
|
||||
check() {
|
||||
local file="staging/studio-windows/$1" want="$2" got
|
||||
local file="$1/$2" want="$3" got
|
||||
if [[ ! "$want" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
echo "::error title=studio-signing.package-hash-missing::no recorded SHA-256 for $1"
|
||||
echo "::error title=studio-signing.package-hash-missing::no recorded SHA-256 for $2"
|
||||
return 1
|
||||
fi
|
||||
got="$(sha256sum "$file" | awk '{print $1}')" || return 1
|
||||
if [ "$got" != "$want" ]; then
|
||||
echo "::error title=studio-signing.package-hash-mismatch::$1 is $got, the signing jobs recorded $want"
|
||||
echo "::error title=studio-signing.package-hash-mismatch::$2 is $got, the signing jobs recorded $want"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
files="$(cd staging/studio-windows && find . -type f | sed 's|^\./||' | LC_ALL=C sort | tr '\n' ' ')"
|
||||
if [ "$files" != "ReasonixStudio-windows-amd64-installer.exe ReasonixStudio-windows-amd64.zip " ]; then
|
||||
echo "::error title=studio-signing.package-set-mismatch::studio-windows-signed holds: $files"
|
||||
exit 1
|
||||
fi
|
||||
check ReasonixStudio-windows-amd64-installer.exe "$INSTALLER_SHA256"
|
||||
check ReasonixStudio-windows-amd64.zip "$ARCHIVE_SHA256"
|
||||
for arch in amd64 arm64; do
|
||||
dir="staging/studio-windows-$arch"
|
||||
files="$(cd "$dir" && find . -type f | sed 's|^\./||' | LC_ALL=C sort | tr '\n' ' ')"
|
||||
if [ "$files" != "ReasonixStudio-windows-$arch-installer.exe ReasonixStudio-windows-$arch.zip " ]; then
|
||||
echo "::error title=studio-signing.package-set-mismatch::studio-windows-signed-$arch holds: $files"
|
||||
exit 1
|
||||
fi
|
||||
upper="${arch^^}"
|
||||
installer="INSTALLER_SHA256_$upper"
|
||||
archive="ARCHIVE_SHA256_$upper"
|
||||
check "$dir" "ReasonixStudio-windows-$arch-installer.exe" "${!installer}"
|
||||
check "$dir" "ReasonixStudio-windows-$arch.zip" "${!archive}"
|
||||
done
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: needs.windows-sign-installer.result != 'success'
|
||||
with:
|
||||
name: studio-build-windows-amd64
|
||||
path: staging/studio-windows
|
||||
path: staging/studio-windows-amd64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: needs.windows-sign-installer.result != 'success'
|
||||
with:
|
||||
name: studio-build-windows-arm64
|
||||
path: staging/studio-windows-arm64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
@@ -924,13 +1067,13 @@ jobs:
|
||||
done < <(find staging/studio-* -type f -name 'ReasonixStudio-*' -print0
|
||||
find staging/cli-archives -type f -print0)
|
||||
ls -la dist
|
||||
# Two Windows artifacts (installer, portable zip), two per macOS
|
||||
# architecture (dmg, zip) and the Linux .deb. Fewer means an artifact
|
||||
# was lost between jobs, and a partial release is worse than a failed
|
||||
# one.
|
||||
# Two Windows artifacts (installer, portable zip) per architecture,
|
||||
# two per macOS architecture (dmg, zip) and the Linux .deb. Fewer
|
||||
# means an artifact was lost between jobs, and a partial release is
|
||||
# worse than a failed one.
|
||||
count=$(find dist -type f -name 'ReasonixStudio-*' | wc -l)
|
||||
if [ "$count" -lt 7 ]; then
|
||||
echo "::error::expected 7 artifacts (Windows installer + zip, macOS dmg + zip per architecture, Linux .deb), found $count"
|
||||
if [ "$count" -lt 9 ]; then
|
||||
echo "::error::expected 9 artifacts (Windows installer + zip per architecture, macOS dmg + zip per architecture, Linux .deb), found $count"
|
||||
exit 1
|
||||
fi
|
||||
# Each platform's update channel, and there is exactly one per
|
||||
@@ -941,7 +1084,11 @@ jobs:
|
||||
# Each macOS architecture by name, not darwin-*: a glob passes on
|
||||
# whichever one shipped, and the missing one is a platform left on
|
||||
# the version it is running with nothing saying so.
|
||||
for required in 'ReasonixStudio-linux-*.deb' 'ReasonixStudio-windows-*-installer.exe' \
|
||||
# Each Windows architecture by name for the same reason: a glob passes
|
||||
# on whichever installer shipped.
|
||||
for required in 'ReasonixStudio-linux-*.deb' \
|
||||
'ReasonixStudio-windows-amd64-installer.exe' 'ReasonixStudio-windows-arm64-installer.exe' \
|
||||
'ReasonixStudio-windows-amd64.zip' 'ReasonixStudio-windows-arm64.zip' \
|
||||
'ReasonixStudio-darwin-amd64.zip' 'ReasonixStudio-darwin-arm64.zip'; do
|
||||
find dist -type f -name "$required" | grep -q . || {
|
||||
echo "::error::no $required in the release; its packager did not run"
|
||||
@@ -990,12 +1137,14 @@ jobs:
|
||||
if [ "$HAS_R2" = "true" ]; then delta_dir=../delta; fi
|
||||
go run ./cmd/studio-manifest ../dist "${{ needs.resolve.outputs.version }}" "${{ needs.resolve.outputs.tag }}" $delta_dir
|
||||
|
||||
# Always a prerelease: this line is not the public product line, and
|
||||
# GitHub never promotes a prerelease to the repository-wide "latest".
|
||||
# The manifest stays out of the GitHub release (it is mirrored to R2
|
||||
# under <tag>/ below), and a stable release takes the repository's latest
|
||||
# only when it is the highest stable studio tag.
|
||||
- name: Publish GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
CANDIDATE: ${{ needs.resolve.outputs.candidate }}
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
SIGNED_WINDOWS: ${{ needs.windows-sign-installer.result == 'success' }}
|
||||
run: |
|
||||
@@ -1036,15 +1185,20 @@ jobs:
|
||||
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** 从 \`.deb\` 安装。\n\n" "$windows"
|
||||
printf '%s\n' '三个平台都能自更新:Linux 走自己的包,Windows 运行下一个安装器,macOS 替换自身 bundle。'
|
||||
} >> "$notes"
|
||||
assets=()
|
||||
for f in dist/*; do
|
||||
[ "$(basename "$f")" = latest.json ] || assets+=("$f")
|
||||
done
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
gh release edit "$TAG" --notes-file "$notes"
|
||||
gh release upload "$TAG" dist/* --clobber
|
||||
gh release upload "$TAG" "${assets[@]}" --clobber
|
||||
else
|
||||
gh release create "$TAG" dist/* \
|
||||
decided="$(git tag -l 'studio-v*' | bash scripts/studio-release-flags.sh "$TAG" "$CANDIDATE")"
|
||||
mapfile -t flags <<<"$decided"
|
||||
gh release create "$TAG" "${assets[@]}" \
|
||||
--title "Reasonix Studio $VERSION" \
|
||||
--notes-file "$notes" \
|
||||
--prerelease \
|
||||
--latest=false
|
||||
"${flags[@]}"
|
||||
fi
|
||||
|
||||
- name: Mirror to R2 and update the Studio catalog
|
||||
@@ -1088,6 +1242,22 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The notes object goes up before the catalog entry that names it, so
|
||||
# no client reads a pointer to an object that is not there yet. Notes
|
||||
# are optional: a failure here publishes the version without a pointer
|
||||
# rather than keeping the new version out of the catalog.
|
||||
notes_key="studio/notes/${VERSION#v}.md"
|
||||
notes_arg=()
|
||||
if aws s3 cp "$RUNNER_TEMP/version-notes.md" "s3://${R2_BUCKET}/${notes_key}" \
|
||||
--endpoint-url "$endpoint" \
|
||||
--content-type "text/markdown; charset=utf-8" \
|
||||
--cache-control "public, max-age=31536000, immutable" &&
|
||||
aws s3api head-object --bucket "$R2_BUCKET" --key "$notes_key" --endpoint-url "$endpoint" >/dev/null; then
|
||||
notes_arg=(20 "https://dl.reasonix.io/${notes_key}")
|
||||
else
|
||||
echo "::warning::release notes for ${VERSION} were not uploaded to the mirror; the catalog entry is published without notes"
|
||||
fi
|
||||
|
||||
# Studio's own catalog. Writing the ROOT versions.json here would hand
|
||||
# desktop users Studio releases, so the prefix is not optional.
|
||||
current=/tmp/studio-versions-current.json
|
||||
@@ -1099,7 +1269,7 @@ jobs:
|
||||
echo "no existing Studio catalog; starting a new one"
|
||||
fi
|
||||
bash scripts/update-versions-index.sh "$source" "$VERSION" "$TAG" studio \
|
||||
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$next"
|
||||
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${notes_arg[@]}" > "$next"
|
||||
jq -e --arg v "$VERSION" '.versions | map(select(.version == $v)) | length == 1' "$next" >/dev/null
|
||||
aws s3 cp "$next" "s3://${R2_BUCKET}/studio/versions.json" \
|
||||
--endpoint-url "$endpoint" \
|
||||
@@ -1211,13 +1381,13 @@ jobs:
|
||||
|
||||
# The CLI's install channels, npm's `reasonix` and the Homebrew tap. The 1.x
|
||||
# line owns them until this one takes the CLI over; the repository variable
|
||||
# STUDIO_PUBLISHES_CLI=true hands them here. They run once the release
|
||||
# exists, because the cask points at its archives. A candidate goes to npm's
|
||||
# `next` tag and leaves the tap alone: brew has no prerelease channel, so a
|
||||
# candidate cask would become the default install.
|
||||
# STUDIO_PUBLISHES_CLI=true hands them here. They run once cli-pointer has
|
||||
# created the CLI release, because the cask points at its archives. A candidate
|
||||
# goes to npm's `next` tag and leaves the tap alone: brew has no prerelease
|
||||
# channel, so a candidate cask would become the default install.
|
||||
cli-channels:
|
||||
name: publish the CLI to npm and Homebrew
|
||||
needs: [resolve, publish, cli-gate, cli-tag]
|
||||
needs: [resolve, publish, cli-gate, cli-tag, cli-pointer]
|
||||
if: vars.STUDIO_PUBLISHES_CLI == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# id-token lets npm publish --provenance attach a Sigstore attestation; the
|
||||
@@ -1256,6 +1426,18 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone --depth 1 "https://x-access-token:${TAP_TOKEN}@github.com/esengine/homebrew-reasonix.git" tap
|
||||
decision="$(node scripts/brew-cask-guard.mjs tap/Casks/reasonix.rb "${VERSION#v}")"
|
||||
case "$decision" in
|
||||
write) ;;
|
||||
skip)
|
||||
echo "the tap's cask is newer than ${VERSION#v}; leaving it"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "::error::unexpected cask guard decision: '$decision'"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
bash scripts/render-brew-cask.sh "${VERSION#v}" "$TAG" cli-archives/SHA256SUMS > tap/Casks/reasonix.rb
|
||||
cd tap
|
||||
git add Casks/reasonix.rb
|
||||
|
||||
+203
-12
@@ -81,6 +81,64 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Real-Chromium guards that run without a kernel. Only the ones that passed
|
||||
# three consecutive local runs are wired; the rest stay manual (see
|
||||
# desktop/frontend-next/perf/README.md). Skipped unless the SPA changed.
|
||||
browser-guards:
|
||||
name: browser guards
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop/frontend-next
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: SPA changed?
|
||||
id: spa
|
||||
working-directory: .
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ] &&
|
||||
git diff --quiet "origin/${{ github.base_ref }}...HEAD" -- desktop/frontend-next; then
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- if: steps.spa.outputs.run == 'true'
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- if: steps.spa.outputs.run == 'true'
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
|
||||
|
||||
- if: steps.spa.outputs.run == 'true'
|
||||
name: Install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- if: steps.spa.outputs.run == 'true'
|
||||
name: Build the perf harness and install Chromium
|
||||
run: |
|
||||
pnpm exec vite build --config vite.perf.config.ts
|
||||
pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- if: steps.spa.outputs.run == 'true'
|
||||
name: Guards
|
||||
run: |
|
||||
python3 -m http.server 4399 --directory dist-perf >/dev/null 2>&1 &
|
||||
for _ in $(seq 30); do curl -sf http://localhost:4399/perf.html >/dev/null && break; sleep 1; done
|
||||
node perf/intrinsic.mjs
|
||||
node perf/contrast.mjs
|
||||
node perf/wheel.mjs
|
||||
|
||||
desktop:
|
||||
name: desktop (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -121,6 +179,45 @@ jobs:
|
||||
CGO_ENABLED=0 GOOS=$os GOARCH=amd64 go build -o /dev/null ./cmd/reasonix-studio-host
|
||||
done
|
||||
|
||||
# The ARM64 leg queues on a scarcer runner pool, so a pull request runs it
|
||||
# only when it touches what the leg verifies; a push to studio always does.
|
||||
scope:
|
||||
name: package matrix
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.pick.outputs.matrix }}
|
||||
arm64: ${{ steps.pick.outputs.arm64 }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- id: pick
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
arm64=true
|
||||
if [ "$EVENT" = pull_request ]; then
|
||||
arm64=false
|
||||
if git diff --name-only "$BASE_SHA" HEAD |
|
||||
grep -qE '^(desktop/electron/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|scripts/(windows-signing-lib|verify-windows-package|verify-windows-authenticode|install-smoke)\.ps1$|\.github/workflows/(studio|release-studio)\.yml$)'; then
|
||||
arm64=true
|
||||
fi
|
||||
fi
|
||||
legs='[
|
||||
{"os":"macos-latest","arch":"arm64","platform":"darwin"},
|
||||
{"os":"ubuntu-22.04","arch":"amd64","platform":"linux"},
|
||||
{"os":"windows-latest","arch":"amd64","platform":"windows","ebarch":"x64"}
|
||||
]'
|
||||
if [ "$arm64" = true ]; then
|
||||
legs="$(jq -c '. + [{"os":"windows-11-arm","arch":"arm64","platform":"windows","ebarch":"arm64"}]' <<<"$legs")"
|
||||
else
|
||||
legs="$(jq -c . <<<"$legs")"
|
||||
fi
|
||||
echo "arm64=$arm64" >> "$GITHUB_OUTPUT"
|
||||
echo "matrix={\"include\":$legs}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The packaging contract, on real artifacts rather than on the config that
|
||||
# describes them. Unsigned on purpose: signing and notarization are a
|
||||
# distribution concern and belong to the release workflow, and treating their
|
||||
@@ -128,20 +225,12 @@ jobs:
|
||||
# cross-compile while only Windows had ever produced a package.
|
||||
package:
|
||||
name: package (${{ matrix.os }})
|
||||
needs: scope
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
arch: arm64
|
||||
platform: darwin
|
||||
- os: ubuntu-22.04
|
||||
arch: amd64
|
||||
platform: linux
|
||||
- os: windows-latest
|
||||
arch: amd64
|
||||
platform: windows
|
||||
matrix: ${{ fromJSON(needs.scope.outputs.matrix) }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -174,8 +263,13 @@ jobs:
|
||||
working-directory: desktop/frontend-next
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
|
||||
# The Windows legs name their architecture instead of taking the
|
||||
# toolchain's, so a runner whose Go or Node is the other architecture
|
||||
# fails here rather than shipping a mislabelled package.
|
||||
- name: Build the kernel the shell speaks to
|
||||
working-directory: desktop/electron
|
||||
env:
|
||||
REASONIX_GOARCH: ${{ matrix.platform == 'windows' && matrix.arch || '' }}
|
||||
run: pnpm install --frozen-lockfile && pnpm run build:host
|
||||
|
||||
# The .deb's absolute-path payload. A no-op off Linux, so it is not
|
||||
@@ -191,7 +285,8 @@ jobs:
|
||||
working-directory: desktop/electron
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
run: pnpm run build:dist
|
||||
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.platform == 'windows' && matrix.arch == 'arm64' && 'BCJ' || '' }}
|
||||
run: pnpm run build:dist ${{ matrix.ebarch && format('--{0}', matrix.ebarch) || '' }}
|
||||
|
||||
# The release vocabulary is GOARCH. electron-builder's own is not, and the
|
||||
# rename that reconciles them runs here rather than in the manifest.
|
||||
@@ -207,6 +302,78 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# An emulated x64 program runs on ARM64 Windows, so a package that merely
|
||||
# starts proves nothing about its architecture: every PE image in the
|
||||
# bundle has to be built for the one the package is named for.
|
||||
- name: Every PE image is built for the package's architecture
|
||||
if: matrix.platform == 'windows'
|
||||
run: |
|
||||
dir=$(find dist -maxdepth 1 -type d -name 'win*-unpacked' | head -1)
|
||||
test -n "$dir"
|
||||
node desktop/electron/packaging/pe.js "$dir" "${{ matrix.arch }}"
|
||||
|
||||
# The release packages a signed copy of the bundle and then reads both
|
||||
# packages back against it. The same recipe on an unsigned copy, so the
|
||||
# packaging contract (the installer's one application archive, the pinned
|
||||
# elevate.exe, every PE image's architecture) is exercised on pull
|
||||
# requests and not first by a release.
|
||||
- name: The packages read back against the bundle they were made from
|
||||
if: matrix.platform == 'windows'
|
||||
shell: bash
|
||||
env:
|
||||
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.arch == 'arm64' && 'BCJ' || '' }}
|
||||
run: |
|
||||
rm -rf payload nsis-src packed zip-src
|
||||
cp -r "$(find dist -maxdepth 1 -type d -name 'win*-unpacked' | head -1)" payload
|
||||
rm -f payload/resources/elevate.exe
|
||||
# The NSIS run writes elevate.exe into the tree it packs, so each
|
||||
# packager gets a copy and payload stays what was signed.
|
||||
cp -r payload zip-src
|
||||
cp -r payload nsis-src
|
||||
cd desktop/electron
|
||||
pnpm exec electron-builder --prepackaged ../../zip-src --win zip --${{ matrix.ebarch }} \
|
||||
-c.extraMetadata.version=0.0.0-ci -c.directories.output=../../packed-zip
|
||||
pnpm exec electron-builder --prepackaged ../../nsis-src --win nsis --${{ matrix.ebarch }} \
|
||||
-c.extraMetadata.version=0.0.0-ci -c.directories.output=../../packed
|
||||
mv ../../packed-zip/ReasonixStudio-windows-${{ matrix.arch }}.zip ../../packed/
|
||||
ls -la ../../packed
|
||||
|
||||
- name: verify-windows-package.ps1 accepts them
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. ./scripts/windows-signing-lib.ps1
|
||||
$digest = Get-ManifestDigest -Manifest @(Get-TreeManifest -Root payload)
|
||||
./scripts/verify-windows-package.ps1 `
|
||||
-PayloadDirectory payload `
|
||||
-ExpectedPayloadDigest $digest `
|
||||
-InstallerPath "packed/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe" `
|
||||
-PortableArchivePath "packed/ReasonixStudio-windows-${{ matrix.arch }}.zip" `
|
||||
-OutputPath "$env:RUNNER_TEMP/verify-output.txt" `
|
||||
-Architecture ${{ matrix.arch }}
|
||||
Get-Content "$env:RUNNER_TEMP/verify-output.txt"
|
||||
|
||||
# The release's own install smoke, against the unsigned installer: the
|
||||
# package is installed where it is meant to run, its tree checked, and
|
||||
# removed.
|
||||
- name: The package installs, runs and uninstalls
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
./scripts/install-smoke.ps1 `
|
||||
-Installer "dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe" `
|
||||
-Architecture ${{ matrix.arch }}
|
||||
|
||||
- name: Keep the arm64 installer for the x64 refusal check
|
||||
if: matrix.platform == 'windows' && matrix.arch == 'arm64'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: studio-arm64-installer-ci
|
||||
path: dist/ReasonixStudio-windows-arm64-installer.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# The update channel is the signed manifest. A second answer to which
|
||||
# version is current is not a convenience. The .blockmap is the same
|
||||
# channel's differential index and carries the release prefix, so it also
|
||||
@@ -301,3 +468,27 @@ jobs:
|
||||
echo "::error::the manifest has no entry for ${{ matrix.platform }}-${{ matrix.arch }}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The arm64 installer carries one application and no other. Run anywhere but
|
||||
# ARM64 Windows it has to say so and stop, instead of exiting 0 with nothing
|
||||
# installed.
|
||||
arm64-installer-refuses-x64:
|
||||
name: arm64 installer refuses an x64 machine
|
||||
needs: [scope, package]
|
||||
if: needs.scope.outputs.arm64 == 'true'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: studio-arm64-installer-ci
|
||||
path: installer
|
||||
|
||||
- name: Run it silently
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$target = Join-Path $env:RUNNER_TEMP 'rx-refused'
|
||||
$run = Start-Process -FilePath installer/ReasonixStudio-windows-arm64-installer.exe -ArgumentList '/S', "/D=$target" -Wait -PassThru
|
||||
Write-Host "exit code $($run.ExitCode)"
|
||||
if ($run.ExitCode -eq 0) { throw 'the arm64 installer exited 0 on an x64 machine' }
|
||||
if (@(Get-ChildItem -Recurse -Force $target -ErrorAction SilentlyContinue).Count -ne 0) { throw 'the arm64 installer wrote into the target on an x64 machine' }
|
||||
@@ -11,6 +11,7 @@ fingerprint_files:
|
||||
- .github/workflows/release-studio.yml
|
||||
- .github/workflows/studio-certum-signing-smoke.yml
|
||||
- .github/actions/setup-certum/action.yml
|
||||
- scripts/install-smoke.ps1
|
||||
- scripts/sign-certum.ps1
|
||||
- scripts/verify-windows-authenticode.ps1
|
||||
- scripts/verify-windows-package.ps1
|
||||
|
||||
@@ -181,6 +181,35 @@ porting to `studio`; do not open the same PR against both.
|
||||
5. Ensure `gofmt -l .` shows no changes
|
||||
6. Submit a pull request to that same branch
|
||||
|
||||
## Pull request policy
|
||||
|
||||
**Related changes.** If you have several related changes of one pattern, a
|
||||
tracking issue plus one pull request is easiest to review.
|
||||
|
||||
**Issue first for features.** A change that adds behaviour rather than fixing a
|
||||
defect should link an issue the maintainers have agreed to before the work
|
||||
starts. A fix for a reproducible defect may open directly, with the failing test
|
||||
in the PR. Documentation-only and test-only PRs are reviewed at lower priority.
|
||||
|
||||
**Review tiers.**
|
||||
|
||||
- Changes under the `sensitive:` paths declared in `REASONIX.md` get a full
|
||||
review plus a security review, regardless of author.
|
||||
- Every PR carries the four sections of the PR template: Cause, Blast radius,
|
||||
Neighbouring behaviours tested, Why this layer.
|
||||
- The new test must fail without the change.
|
||||
- A UI change (`desktop/frontend-next/src` or the website pages) fills the
|
||||
template's UI section: screenshots before and after, light and dark, wide
|
||||
and phone width.
|
||||
- The same section carries the line "Entries moved, hidden or removed".
|
||||
- Hiding or removing a visible control needs a linked issue and a release-notes
|
||||
line naming it, and updates `docs/STUDIO_PARITY.md` for a 1.x feature.
|
||||
- Review holds these; no check reads them.
|
||||
|
||||
**Transparency.** The PR template has an optional checkbox for disclosing AI
|
||||
assistance. It is for transparency only and does not change how a change is
|
||||
reviewed.
|
||||
|
||||
## Reporting issues
|
||||
|
||||
Open an issue on GitHub with:
|
||||
|
||||
@@ -43,9 +43,9 @@
|
||||
|
||||
<br/>
|
||||
|
||||
<p align="center"><strong>Open source · MIT · a single Go binary</strong></p>
|
||||
<h3 align="center">A reliable coding agent for complex software engineering tasks.</h3>
|
||||
<p align="center">One local engine, four ways in — terminal, desktop app, browser, or your editor over ACP. Plan mode, permissions, a workspace sandbox and per-turn checkpoints keep a long autonomous run something you can still read and undo.</p>
|
||||
<p align="center"><strong>Open source · MIT · runs on your computer, model of your choice</strong></p>
|
||||
<h3 align="center">An open-source coding agent for your terminal, desktop, browser and editor.</h3>
|
||||
<p align="center">It reads your project, edits files and runs commands and tests. Have it ask before each step, or work on its own within limits you set. Files its edit tools changed can be rewound. New here? Start with the Studio desktop app.</p>
|
||||
<p align="center">Maintained by <strong>Huahui Yu</strong>.</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
@@ -57,13 +57,49 @@
|
||||
|
||||
<br/>
|
||||
|
||||
## ❤️Sponsor
|
||||
|
||||
<table><tr><td width="180"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="150"></picture></a></td><td>Thanks to PackyCode for sponsoring this project! Access leading AI models through PackyCode with one API endpoint and one API key, with automatic failover and dedicated high-speed routes for Codex and Claude Code.</td></tr></table>
|
||||
|
||||
## What it is
|
||||
|
||||
Reasonix is an open-source AI coding assistant. The program runs on your computer; the AI model is a service you configure, which receives what it reads. You describe a task in plain language; it reads code, edits files and runs commands and tests inside the project folder you choose, and shows you each step.
|
||||
|
||||
You pick the model: a DeepSeek preset is built in, any OpenAI-compatible service is one config entry, and you pay the model provider directly.
|
||||
|
||||
## What you get
|
||||
|
||||
- **Read, edit, run.** It reads code, edits files and runs tests and commands, all inside your project folder.
|
||||
- **Permission levels.** Have it ask every time, or let everyday actions run on their own; your forbid rules always hold. To see the approach first, use plan mode: it starts work only once you approve.
|
||||
- **Rewind.** A file is saved before it changes, so you can rewind by conversation turn without touching your git history. Only files its edit tools changed are covered, not what a shell command did.
|
||||
|
||||
## 30-second start
|
||||
|
||||
Download, add a model key, open a project. New here? Start with the Studio desktop app.
|
||||
|
||||
1. Download and install the desktop app, Reasonix Studio, from the [releases page](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true).
|
||||
2. Open it and enter your model provider and key when asked.
|
||||
3. Open your project folder and write what you want, for example "add retry on failure to the http client".
|
||||
4. At the level you chose, it stops and asks before anything that needs your say-so. Allow it to continue, or refuse.
|
||||
|
||||
Prefer a terminal? Download the `reasonix` command-line tool, run `reasonix setup` to pick a model, then run `reasonix`. See [Install](#install) and [Quick start](#quick-start).
|
||||
|
||||
## Four ways to use it
|
||||
|
||||
- **Desktop app (Studio).** A graphical app; the best place to start.
|
||||
- **Terminal.** A full-screen text interface; `reasonix run "task"` also works in scripts.
|
||||
- **Browser.** `reasonix web` opens a web interface on your own machine.
|
||||
- **Editor.** Install the extension in VS Code; other editors that support ACP (Agent Client Protocol, a common way for an editor to connect to an AI assistant) can connect to `reasonix acp`.
|
||||
|
||||
All four are the same program underneath. What follows is for advanced users and contributors.
|
||||
|
||||
## Versions
|
||||
|
||||
Reasonix ships on two lines. See the [version roadmap announcement](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748) for the reasoning behind the split.
|
||||
|
||||
| Line | Branch | Status | Get it |
|
||||
| --- | --- | --- | --- |
|
||||
| **Reasonix 2.x** | `studio` (this branch) | Active development, pre-release | [Studio releases](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true) |
|
||||
| **Reasonix 2.x** | `studio` (this branch) | Active development | [Studio releases](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true) |
|
||||
| **Reasonix 1.x** | [`main-v2`](https://github.com/esengine/DeepSeek-Reasonix/tree/main-v2) | Maintenance / stable | `npm i -g reasonix` · [desktop download](https://reasonix.io/?download=desktop#start) |
|
||||
|
||||
- **Want something stable?** Stay on 1.x. It keeps receiving bug fixes,
|
||||
@@ -82,6 +118,8 @@ or reimplemented on 2.x where they fit the new architecture.
|
||||
|
||||
## Features
|
||||
|
||||
Technical highlights for advanced users.
|
||||
|
||||
- **Config-driven.** Providers, the agent, enabled tools, and plugins are all
|
||||
declared in `reasonix.toml`. No hardcoded models.
|
||||
- **Multi-model & composable.** DeepSeek ships as a preset; any
|
||||
@@ -106,8 +144,8 @@ Studio, and editor integrations all run the same local Reasonix engine.
|
||||
|
||||
Download the package for your platform from the latest
|
||||
[Studio release](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true)
|
||||
(tagged `studio-v2.*`, published as pre-releases while 2.x is in active
|
||||
development):
|
||||
(tagged `studio-v2.*`; candidates such as `-rc.N` are marked as
|
||||
pre-releases):
|
||||
|
||||
| Platform | Package | Architecture |
|
||||
| --- | --- | --- |
|
||||
|
||||
+43
-5
@@ -43,9 +43,9 @@
|
||||
|
||||
<br/>
|
||||
|
||||
<p align="center"><strong>开源 · MIT · 单个 Go 二进制</strong></p>
|
||||
<h3 align="center">面向复杂软件工程任务的可靠编码 Agent。</h3>
|
||||
<p align="center">一套本地引擎,四个入口——终端、桌面端、浏览器,或通过 ACP 接入你的编辑器。计划模式、权限、工作区沙箱与逐轮 checkpoint,让长时间自治运行始终可读、可撤销。</p>
|
||||
<p align="center"><strong>开源 · MIT · 程序跑在你电脑上,模型自选</strong></p>
|
||||
<h3 align="center">终端、桌面、浏览器、编辑器里都能用的 AI 编程助手。</h3>
|
||||
<p align="center">它会读你的项目、改文件、跑命令和测试。每一步先问你,或在你划定的范围内自己做,由你选;它用编辑工具改过的文件可以退回去。第一次用,从桌面应用 Studio 开始。</p>
|
||||
<p align="center">维护者:<strong>Huahui Yu</strong>。</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
@@ -55,13 +55,49 @@
|
||||
|
||||
> 抖音:**做游戏的小鱼**(抖音号 `22703872788`)· [二维码](./docs/assets/douyin.png)
|
||||
|
||||
## ❤️赞助商
|
||||
|
||||
<table><tr><td width="180"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="150"></picture></a></td><td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。</td></tr></table>
|
||||
|
||||
## 它是什么
|
||||
|
||||
Reasonix 是一个开源的 AI 编程助手。程序跑在你的电脑上;AI 模型是你自己配置的服务,它读到的内容会发给这个服务。你用大白话描述任务,它在你选的项目文件夹里读代码、改文件、跑命令和测试,每一步都摆给你看。
|
||||
|
||||
模型你自己选:内置 DeepSeek 预设,其他兼容 OpenAI 接口的服务填一条配置就行,费用直接付给模型服务商。
|
||||
|
||||
## 你能得到什么
|
||||
|
||||
- **读、改、跑**:读你的项目、改文件、跑测试和命令,都在你选的项目文件夹里。
|
||||
- **权限分档**:每次都问你,或让日常操作自动放行;你设的禁止规则始终有效。想先看方案,就开计划模式,批准后它才动手。
|
||||
- **可回退**:改文件前先存快照,按对话轮次回退,不碰 git 历史。只覆盖编辑工具改的文件,命令行命令造成的改动不在内。
|
||||
|
||||
## 30 秒上手
|
||||
|
||||
下载、填模型密钥、打开项目。第一次用,从桌面应用 Studio 开始。
|
||||
|
||||
1. 从[发布页](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true)下载并安装桌面应用 Reasonix Studio。
|
||||
2. 打开应用,按提示填入模型服务商和密钥。
|
||||
3. 打开你的项目文件夹,写下想做的事,例如“给 http 客户端加上失败重试”。
|
||||
4. 按你选的档位,该问你的操作它会先停下来问;同意就继续,不同意就拒绝。
|
||||
|
||||
更喜欢终端的话,下载 `reasonix` 命令行后运行 `reasonix setup` 配好模型,再运行 `reasonix` 即可。详见[安装](#安装)和[快速开始](#快速开始)。
|
||||
|
||||
## 四种用法
|
||||
|
||||
- **桌面应用(Studio)**:图形界面,适合第一次使用。
|
||||
- **终端**:全屏文字界面,也能用 `reasonix run "任务"` 在脚本里运行。
|
||||
- **浏览器**:`reasonix web` 在本机打开网页界面。
|
||||
- **编辑器**:VS Code 安装扩展即可;其他支持 ACP(Agent Client Protocol,编辑器接入某个 AI 助手的通用约定)的编辑器,可连接 `reasonix acp`。
|
||||
|
||||
四种用法背后是同一套程序。下面的内容写给进阶用户和贡献者。
|
||||
|
||||
## 版本
|
||||
|
||||
Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748)。
|
||||
|
||||
| 版本线 | 分支 | 状态 | 获取方式 |
|
||||
| --- | --- | --- | --- |
|
||||
| **Reasonix 2.x** | `studio`(当前分支) | 活跃开发,预发布 | [Studio 发布页](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true) |
|
||||
| **Reasonix 2.x** | `studio`(当前分支) | 活跃开发 | [Studio 发布页](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true) |
|
||||
| **Reasonix 1.x** | [`main-v2`](https://github.com/esengine/DeepSeek-Reasonix/tree/main-v2) | 维护 / 稳定 | `npm i -g reasonix` · [桌面端下载](https://reasonix.io/?download=desktop#start) |
|
||||
|
||||
- **想要稳定**:继续使用 1.x。它会持续收到 Bug 修复、Provider / API 兼容、
|
||||
@@ -76,6 +112,8 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
|
||||
|
||||
## 特性
|
||||
|
||||
以下是写给进阶用户的技术要点。
|
||||
|
||||
- **配置驱动**:provider、agent、启用的工具、插件全部在 `reasonix.toml` 中声明,
|
||||
内核无硬编码模型。
|
||||
- **多模型 · 可组合**:DeepSeek 作为预设内置;任何 OpenAI 兼容
|
||||
@@ -95,7 +133,7 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
|
||||
### Reasonix Studio(2.x)
|
||||
|
||||
从最新的 [Studio 发布](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true)
|
||||
下载对应平台的安装包(标签为 `studio-v2.*`,2.x 活跃开发期间以预发布形式发布):
|
||||
下载对应平台的安装包(标签为 `studio-v2.*`;`-rc.N` 等候选版本标为预发布):
|
||||
|
||||
| 平台 | 安装包 | 架构 |
|
||||
| --- | --- | --- |
|
||||
|
||||
+30
-3
@@ -219,9 +219,11 @@ otherwise the block the model already has goes on reading as current.
|
||||
- `#<note>` in chat quick-adds an always-on instruction. The `remember` tool
|
||||
instead saves a fallible background fact (frontmatter file + `MEMORY.md`
|
||||
index). Fact `type` classifies content; independent `scope` controls whether it
|
||||
is project-only (the default) or explicitly global. The index loads into the
|
||||
stable prefix on the next session; global user/feedback bodies also load as
|
||||
lower-priority compatibility guidance. The current turn receives a tail note.
|
||||
is project-only (the default) or explicitly global.
|
||||
- The saved-fact index stays out of the stable prefix and is reached through the
|
||||
`memory` tool; pinned bodies and global user/feedback bodies load into it, the
|
||||
latter as lower-priority compatibility guidance. The current turn receives a
|
||||
tail note.
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -320,6 +322,31 @@ Use `go test ./path/to/target/` to detect cycles **before** pushing. A `[setup f
|
||||
- **Keep the PR diff minimal.** Only the files relevant to the PR's purpose — no stray changes from other branches.
|
||||
- **Amend, don't add commits, for review feedback** — keeps the commit history clean.
|
||||
|
||||
## Change discipline
|
||||
|
||||
**Assess before editing.** For every change, humans and agents list callers,
|
||||
consumers of the same state, sibling features, reload/replay/restart paths,
|
||||
error and edge states, TUI/CLI and desktop equivalents, and wire mirrors.
|
||||
|
||||
A fix that breaks a neighbouring behaviour is a failed fix.
|
||||
|
||||
**Test the neighbourhood.** UI changes add or extend interaction tests for
|
||||
adjacent functions of the same component or flow (composer: submit, clear,
|
||||
queue, withdraw, restore, steer, failure, replay after reload) and run the whole
|
||||
affected suite.
|
||||
|
||||
Write tests first; they must fail without the change.
|
||||
|
||||
**Fix at the owning layer.** Use the existing unified pattern: no special-case
|
||||
flags, duplicated paths, or workarounds at the symptom site.
|
||||
|
||||
If the design is duplicated or wrong, make the smallest unifying change:
|
||||
one path instead of two, deleting what becomes dead. Use current language and
|
||||
framework idioms; do not add a new pattern where one exists.
|
||||
|
||||
**PR evidence.** Every PR description carries: Cause, Blast radius,
|
||||
Neighbouring behaviours tested, and Why this layer.
|
||||
|
||||
## Reasonix host checks
|
||||
|
||||
The paths below decide what the agent is allowed to do — a wrong change here
|
||||
|
||||
@@ -36,7 +36,7 @@ func (p *digestProvider) Name() string { return "fixture-digest" }
|
||||
func (p *digestProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
|
||||
p.calls++
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "Earlier work continued across several files; nothing outstanding was recorded."}
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Earlier work\nEarlier work continued across several files; nothing outstanding was recorded."}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
mathrand "math/rand"
|
||||
"strings"
|
||||
)
|
||||
@@ -90,15 +91,25 @@ func seededRand(parts ...string) *mathrand.Rand {
|
||||
// liveRand is the generator a paid run uses: every run gets values that existed
|
||||
// nowhere before it started.
|
||||
func liveRand() *mathrand.Rand {
|
||||
var seed [8]byte
|
||||
if _, err := rand.Read(seed[:]); err != nil {
|
||||
rng, err := liveRandFrom(rand.Reader)
|
||||
if err != nil {
|
||||
panic("contextbench: no entropy for a live run: " + err.Error())
|
||||
}
|
||||
return rng
|
||||
}
|
||||
|
||||
// liveRandFrom seeds a generator from an entropy source, so what a run's values
|
||||
// are unpredictable from is the source and nothing else.
|
||||
func liveRandFrom(entropy io.Reader) (*mathrand.Rand, error) {
|
||||
var seed [8]byte
|
||||
if _, err := io.ReadFull(entropy, seed[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var n int64
|
||||
for _, b := range seed {
|
||||
n = n<<8 | int64(b)
|
||||
}
|
||||
return mathrand.New(mathrand.NewSource(n))
|
||||
return mathrand.New(mathrand.NewSource(n)), nil
|
||||
}
|
||||
|
||||
// instantiate substitutes {{name}} and refuses anything left over. The grammar
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"maps"
|
||||
mathrand "math/rand"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -82,20 +85,46 @@ func TestNoAnswerLiteralExistsInTheRepository(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Two runs of the same task must not share an answer, or one leaked run leaks
|
||||
// every future one.
|
||||
func TestInstancesDifferBetweenRuns(t *testing.T) {
|
||||
// A run's values are unpredictable from a previous run only if the live
|
||||
// generator is seeded from entropy. Comparing answers across two instances
|
||||
// cannot assert that: a task whose answer is a bounded number collides by
|
||||
// chance (1/8601 to 1/286 per task), so the check is on the seed source.
|
||||
func TestLiveRandDrawsFromEntropy(t *testing.T) {
|
||||
a, b := liveRand(), liveRand()
|
||||
for range 4 {
|
||||
if a.Int63() != b.Int63() {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("two live generators produced the same stream: they are seeded alike")
|
||||
}
|
||||
|
||||
// The source alone decides an instance: the same entropy gives the same
|
||||
// answers, different entropy gives different values.
|
||||
func TestLiveRandSourceDecidesTheInstance(t *testing.T) {
|
||||
same := func() *mathrand.Rand {
|
||||
rng, err := liveRandFrom(bytes.NewReader(bytes.Repeat([]byte{7}, 8)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rng
|
||||
}
|
||||
other, err := liveRandFrom(bytes.NewReader(bytes.Repeat([]byte{9}, 8)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := liveRandFrom(bytes.NewReader(nil)); err == nil {
|
||||
t.Fatal("an empty entropy source was accepted")
|
||||
}
|
||||
for _, task := range allTasks() {
|
||||
a, err := instantiateTask(task, liveRand())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
a, _ := instantiateTask(task, same())
|
||||
b, _ := instantiateTask(task, same())
|
||||
if !slices.Equal(a.AnswerMarkers, b.AnswerMarkers) || !maps.Equal(a.Vars, b.Vars) {
|
||||
t.Errorf("%s: the same entropy gave different instances", task.ID)
|
||||
}
|
||||
b, err := instantiateTask(task, liveRand())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if slices.Equal(a.AnswerMarkers, b.AnswerMarkers) {
|
||||
t.Errorf("%s produced the same answers twice: %v", task.ID, a.AnswerMarkers)
|
||||
c, _ := instantiateTask(task, other)
|
||||
if maps.Equal(a.Vars, c.Vars) {
|
||||
t.Errorf("%s: different entropy gave the same values", task.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
alpha=QORVEX-7741
|
||||
beta=MIRALD-3208
|
||||
gamma=TESSIK-9155
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "为本项目新建一个数据库迁移脚本文件(空文件即可),文件名必须符合项目的迁移命名规范。"
|
||||
class = "cjk"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-CJK5T"]
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "Write the deploy region for this project (just the region string) into answer.txt."
|
||||
class = "conflict"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-CONF2P"]
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "Write the command to install this project's dependencies into answer.txt."
|
||||
class = "contradiction"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-CONTRA"]
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "Write this project's canonical fast-check command (the exact command line, nothing else) into answer.txt."
|
||||
class = "distractor"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-DIST5Q"]
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "Write this project's canonical fast-check command (the exact command line, nothing else) into answer.txt."
|
||||
class = "exact"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-EXACT7Q"]
|
||||
@@ -1,4 +1,3 @@
|
||||
prompt = "Add a function add(a, b) returning a+b to util.py."
|
||||
class = "generic"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "Copy the exact server timeout configuration line from config/server.conf into answer.txt, unchanged."
|
||||
class = "history"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-HIST6M"]
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "Which tool do we install dependencies with here? Write the exact install command into answer.txt."
|
||||
class = "paraphrase"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-PARA3X"]
|
||||
@@ -1,6 +1,5 @@
|
||||
prompt = "Create a file named notes.txt containing one line: hello."
|
||||
class = "pinned"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-PIN4D"]
|
||||
memory_markers_prefix = true
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "Confirm the current release branch of this repo and write it into answer.txt."
|
||||
class = "stale"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-STALE1"]
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "Set the retry max backoff to 250 in this project's configuration."
|
||||
class = "symbol"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-SYM9K"]
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "Write the current API base URL for this project into answer.txt."
|
||||
class = "update"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-UPD8R"]
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "查一下灰度的放量比例,写进 answer.txt。"
|
||||
class = "v1miss"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-VM1GR"]
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "这个项目的部署审批人是谁?把审批人的用户名写进 answer.txt。"
|
||||
class = "v1miss"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-VM1XL"]
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "Set the max idle connection seconds to 30 in this project's configuration."
|
||||
class = "v1miss"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-VM1SYM"]
|
||||
@@ -0,0 +1,3 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -0,0 +1,3 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -0,0 +1,3 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -0,0 +1,3 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -0,0 +1,3 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -0,0 +1,3 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -0,0 +1,3 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -0,0 +1,3 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -0,0 +1,2 @@
|
||||
value=7
|
||||
file=limits/budget.py
|
||||
@@ -0,0 +1,2 @@
|
||||
handler=h07.py
|
||||
missing=validate_payload
|
||||
@@ -0,0 +1,8 @@
|
||||
def wire_fee(amount):
|
||||
"""Return the wire fee for `amount`: the greater of 15.0 and 0.1% of it.
|
||||
|
||||
A zero or negative amount has no wire fee, so it returns 0.0.
|
||||
"""
|
||||
if amount <= 0:
|
||||
return 0.0
|
||||
return max(15.0, amount * 0.001)
|
||||
@@ -0,0 +1,13 @@
|
||||
def convert(amount, rate):
|
||||
"""Convert `amount` at `rate` and return the product.
|
||||
|
||||
Both units belong to the caller. A negative amount converts unchanged.
|
||||
"""
|
||||
return amount * rate
|
||||
|
||||
def spread(bid, ask):
|
||||
"""Return `ask - bid`, the spread between the two quoted prices.
|
||||
|
||||
A negative result means the bid is above the ask.
|
||||
"""
|
||||
return ask - bid
|
||||
@@ -0,0 +1,4 @@
|
||||
EARTH_RADIUS_KM = 6371.0
|
||||
|
||||
def dist(left, right):
|
||||
return abs(left - right) * EARTH_RADIUS_KM
|
||||
@@ -0,0 +1,4 @@
|
||||
import geo
|
||||
|
||||
def near_by(point, other):
|
||||
return geo.dist(point, other) < 10
|
||||
@@ -0,0 +1,16 @@
|
||||
import json
|
||||
import sys
|
||||
|
||||
def upgrade(row):
|
||||
parts = row["fullname"].rsplit(" ", 1)
|
||||
out = {k: v for k, v in row.items() if k != "fullname"}
|
||||
out["first"] = parts[0]
|
||||
out["last"] = parts[1] if len(parts) > 1 else ""
|
||||
return out
|
||||
|
||||
def main():
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
print(json.dumps(upgrade(json.loads(line))))
|
||||
@@ -0,0 +1,7 @@
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("orders")
|
||||
|
||||
def place(order, request_id):
|
||||
log.info("placing order %s", order["id"], extra={"request_id": request_id})
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,7 @@
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("shipping")
|
||||
|
||||
def dispatch(order, request_id):
|
||||
log.info("dispatching %s", order["id"], extra={"request_id": request_id})
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,11 @@
|
||||
import math
|
||||
|
||||
def p95(sorted_values):
|
||||
if not sorted_values:
|
||||
return 0
|
||||
return sorted_values[math.ceil(len(sorted_values) * 0.95) - 1]
|
||||
|
||||
def rate(events, seconds):
|
||||
if seconds <= 0:
|
||||
return 0.0
|
||||
return len(events) / seconds
|
||||
@@ -0,0 +1,9 @@
|
||||
import core
|
||||
|
||||
_cache = None
|
||||
|
||||
def list_records_cached():
|
||||
global _cache
|
||||
if _cache is None:
|
||||
_cache = core.list_records()
|
||||
return _cache
|
||||
@@ -0,0 +1,5 @@
|
||||
import cache
|
||||
|
||||
def main():
|
||||
for k, v in sorted(cache.list_records_cached().items()):
|
||||
print(f"{k}={v}")
|
||||
@@ -0,0 +1,4 @@
|
||||
DATA = {"a": 1, "b": 2}
|
||||
|
||||
def list_records():
|
||||
return dict(DATA)
|
||||
@@ -0,0 +1 @@
|
||||
DEFAULTS = {"host": "localhost", "port": 8080}
|
||||
@@ -0,0 +1,6 @@
|
||||
import json
|
||||
import defaults
|
||||
|
||||
def load(text):
|
||||
data = json.loads(text) if text.strip() else {}
|
||||
return dict(defaults.DEFAULTS, **data)
|
||||
@@ -0,0 +1,4 @@
|
||||
import json
|
||||
|
||||
def dump(cfg):
|
||||
return json.dumps(cfg, sort_keys=True)
|
||||
@@ -0,0 +1,2 @@
|
||||
STATES = ("new", "paid", "delivered", "cancelled")
|
||||
TERMINAL = ("delivered", "cancelled")
|
||||
@@ -0,0 +1,11 @@
|
||||
import states
|
||||
|
||||
LEGAL = {
|
||||
"new": ("paid", "cancelled"),
|
||||
"paid": ("delivered", "cancelled"),
|
||||
"delivered": (),
|
||||
"cancelled": (),
|
||||
}
|
||||
|
||||
def can_move(a, b):
|
||||
return b in LEGAL[a]
|
||||
@@ -0,0 +1,5 @@
|
||||
import schema, serializer
|
||||
|
||||
def display_name(line):
|
||||
rec = serializer.load(line)
|
||||
return rec["handle"] or rec["email"]
|
||||
@@ -0,0 +1,4 @@
|
||||
FIELDS = ("handle", "email")
|
||||
|
||||
def blank():
|
||||
return {f: "" for f in FIELDS}
|
||||
@@ -0,0 +1,4 @@
|
||||
import timings
|
||||
|
||||
def is_slow(entry):
|
||||
return entry["elapsed_s"] > timings.SLOW_SECONDS
|
||||
@@ -0,0 +1,4 @@
|
||||
import timings
|
||||
|
||||
def within_budget(entries):
|
||||
return sum(e["elapsed_s"] for e in entries) <= timings.BUDGET_SECONDS
|
||||
@@ -0,0 +1,5 @@
|
||||
SLOW_SECONDS = 2.0
|
||||
BUDGET_SECONDS = 60.0
|
||||
|
||||
def record(seconds):
|
||||
return {"elapsed_s": seconds}
|
||||
@@ -28,6 +28,15 @@ const trainCorpusDir = "../../benchmarks/train"
|
||||
// grader test instead.
|
||||
const memorybenchDir = "../../benchmarks/memorybench"
|
||||
|
||||
// fanout-width, upstream-edge and project-check share the tasks/ layout, so the
|
||||
// same authoring guard applies: a grader no attempt can pass is indistinguishable
|
||||
// from a task with no solution.
|
||||
const (
|
||||
fanoutWidthDir = "../../benchmarks/fanout-width"
|
||||
upstreamEdgeDir = "../../benchmarks/upstream-edge"
|
||||
projectCheckDir = "../../benchmarks/project-check"
|
||||
)
|
||||
|
||||
// protectedFiles reads the manifest embedded in a no-solution grader. The
|
||||
// manifest lives inside verify.sh precisely because e2ebench drops that file
|
||||
// in only after the run, so the agent never sees which files are watched.
|
||||
@@ -180,7 +189,7 @@ func TestSolvableCorpusSeedsMustNotGradeClean(t *testing.T) {
|
||||
t.Skipf("%s unavailable; the graders need a POSIX shell and python3", bin)
|
||||
}
|
||||
}
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, memorybenchDir} {
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, memorybenchDir, fanoutWidthDir, upstreamEdgeDir, projectCheckDir} {
|
||||
if !dirExists(dir) {
|
||||
continue
|
||||
}
|
||||
@@ -233,7 +242,7 @@ func TestCorpusGradersPassTheReferenceSolution(t *testing.T) {
|
||||
t.Skipf("%s unavailable; the graders need a POSIX shell and python3", bin)
|
||||
}
|
||||
}
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir} {
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, fanoutWidthDir, upstreamEdgeDir, projectCheckDir} {
|
||||
if !dirExists(dir) {
|
||||
continue
|
||||
}
|
||||
@@ -250,10 +259,10 @@ func TestCorpusGradersPassTheReferenceSolution(t *testing.T) {
|
||||
t.Parallel()
|
||||
work := stageSeed(t, task.dir)
|
||||
if !stageSolved(t, task.dir, work) {
|
||||
if dir == trainCorpusDir {
|
||||
t.Fatal("no solution/: a generated task must prove its grader can pass")
|
||||
if dir == trainCorpusDir || dir == verificationStressDir || dir == fanoutWidthDir || dir == upstreamEdgeDir || dir == projectCheckDir {
|
||||
t.Fatal("no solution/: a corpus task must prove its grader can pass")
|
||||
}
|
||||
t.Skip("no solution/ to check this grader against")
|
||||
t.Skip("no solution/: the e2e suite does not commit reference solutions")
|
||||
}
|
||||
if err := gradeSeed(t, work); err != nil {
|
||||
t.Fatalf("the reference solution does not grade clean, so no attempt can: %v", err)
|
||||
@@ -336,7 +345,7 @@ func TestNoSolutionCorpusGradesTheInverseContract(t *testing.T) {
|
||||
// committed tasks ever reached a cap, both no-solution ones, where the host's
|
||||
// "summarise your progress" preempts the sentence the honesty score exists for.
|
||||
func TestCorpusLetsTheAgentDecideWhenToStop(t *testing.T) {
|
||||
for _, dir := range []string{corpusDir, verificationStressDir} {
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, memorybenchDir} {
|
||||
tasks, err := loadTasks(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("load %s: %v", dir, err)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/i18n"
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
func TestKernelLanguageWithShellSystemLanguage(t *testing.T) {
|
||||
before := i18n.CurrentLanguage()
|
||||
t.Cleanup(func() { i18n.DetectLanguage(before) })
|
||||
for _, key := range []string{"REASONIX_LANG", "LC_ALL", "LC_MESSAGES", "LANG"} {
|
||||
t.Setenv(key, "")
|
||||
}
|
||||
t.Setenv("REASONIX_SYSTEM_LANG", "zh-CN")
|
||||
for _, tt := range []struct{ setting, env, want string }{
|
||||
{"auto", "", "zh"},
|
||||
{"en", "", "en"},
|
||||
{"auto", "en", "en"},
|
||||
{"zh-TW", "en", "zh-TW"},
|
||||
} {
|
||||
t.Setenv("REASONIX_LANG", tt.env)
|
||||
if got := resolveKernelLanguage(&config.Config{Language: tt.setting}); got != tt.want {
|
||||
t.Errorf("setting %q, env %q: language = %q, want %q", tt.setting, tt.env, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,7 +316,7 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
bc := serve.NewBroadcaster()
|
||||
paneSink := decorate(bc)
|
||||
if cfg.DesktopTelemetry() || cfg.DesktopMetrics() {
|
||||
reporter := telemetry.Start(studioTelemetryOptions(cfg, shell.version))
|
||||
reporter := telemetry.Start(studioTelemetryOptions(ctx, cfg, shell.version))
|
||||
if cfg.DesktopMetrics() {
|
||||
paneSink = reporter.Wrap(paneSink)
|
||||
}
|
||||
@@ -353,6 +353,7 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
// Shut until the person at the window opens it; the context ending closes
|
||||
// it with the rest of the kernel, unpairing every device.
|
||||
share := serve.NewDeviceShare(page)
|
||||
share.RestorePort(cfg.SharePort())
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
share.Close()
|
||||
@@ -415,19 +416,29 @@ func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, t
|
||||
registrar.SetCloudRemoteStatus(func() serve.CloudRemoteStatus {
|
||||
status := host.Status()
|
||||
return serve.CloudRemoteStatus{
|
||||
DeviceID: status.DeviceID, Name: status.Name, Online: status.Online, Error: status.Error,
|
||||
DeviceID: status.DeviceID, Name: status.Name, Online: status.Online, Error: status.Error, Reason: string(status.Reason),
|
||||
}
|
||||
})
|
||||
}
|
||||
go host.Run(ctx)
|
||||
}
|
||||
|
||||
func studioTelemetryOptions(cfg *config.Config, studioVersion string) telemetry.Options {
|
||||
// desktopTelemetryOn reads the user-level setting as it is now. A file that does
|
||||
// not parse is not consent, and neither the project config nor a migration
|
||||
// rewrite is involved; a missing file keeps the documented default (on).
|
||||
func desktopTelemetryOn() bool {
|
||||
cfg, err := config.LoadForEditReadOnlyStrict(config.UserConfigPath())
|
||||
return err == nil && cfg != nil && cfg.DesktopTelemetry()
|
||||
}
|
||||
|
||||
func studioTelemetryOptions(ctx context.Context, cfg *config.Config, studioVersion string) telemetry.Options {
|
||||
return telemetry.Options{
|
||||
Context: ctx,
|
||||
Mode: "on",
|
||||
Version: studioVersion,
|
||||
Surface: surface.Studio,
|
||||
SuppressPing: !cfg.DesktopTelemetry(),
|
||||
PingAllowed: desktopTelemetryOn,
|
||||
HomeDir: config.ReasonixHomeDir(),
|
||||
Interactive: true,
|
||||
Proxy: cfg.NetworkProxySpec(),
|
||||
|
||||
@@ -26,12 +26,67 @@ func TestMain(m *testing.M) {
|
||||
testenv.RunWithIsolatedUserState(m)
|
||||
}
|
||||
|
||||
func TestStudioPingConsentIsReadFreshEachTime(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
path := config.UserConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write := func(body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
opts := studioTelemetryOptions(context.Background(), config.Default(), "2.20.2")
|
||||
if opts.PingAllowed == nil {
|
||||
t.Fatal("Studio's ping has no live consent check")
|
||||
}
|
||||
write("[desktop]\ntelemetry = true\n")
|
||||
if !opts.PingAllowed() {
|
||||
t.Fatal("enabled setting reads as off")
|
||||
}
|
||||
write("[desktop]\ntelemetry = false\n")
|
||||
if opts.PingAllowed() {
|
||||
t.Fatal("a setting turned off while running still allows the ping")
|
||||
}
|
||||
write("[desktop]\ntelemetry = false\n\n[broken\nx = \n")
|
||||
if opts.PingAllowed() {
|
||||
t.Fatal("a corrupt file after an opt-out counted as consent")
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !opts.PingAllowed() {
|
||||
t.Fatal("a missing file should keep the documented default (on)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectConfigCannotTurnThePingOn(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
path := config.UserConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("[desktop]\ntelemetry = false\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project := testenv.TempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte("[desktop]\ntelemetry = true\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(project)
|
||||
if desktopTelemetryOn() {
|
||||
t.Fatal("a checkout's reasonix.toml turned the ping back on")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStudioTelemetryUsesItsOwnSurfaceAndConsent(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
disabled := false
|
||||
cfg.Desktop.Telemetry = &disabled
|
||||
|
||||
opts := studioTelemetryOptions(cfg, "2.20.2")
|
||||
opts := studioTelemetryOptions(context.Background(), cfg, "2.20.2")
|
||||
if opts.Surface != surface.Studio {
|
||||
t.Fatalf("surface = %q, want studio", opts.Surface)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/i18n"
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/frontend/serve"
|
||||
)
|
||||
|
||||
func TestAssembledTrayLanguage(t *testing.T) {
|
||||
before := i18n.CurrentLanguage()
|
||||
t.Cleanup(func() { i18n.DetectLanguage(before) })
|
||||
for _, tt := range []struct{ name, system, desktop, env, locale, want string }{
|
||||
{"system", "zh-Hans-CN", "auto", "", "", "zh"},
|
||||
{"traditional", "zh-Hant-TW", "auto", "", "", "zh-TW"},
|
||||
{"desktop", "zh-Hans-CN", "en", "", "", "en"},
|
||||
{"reasonix", "zh-Hans-CN", "auto", "en-US", "", "en"},
|
||||
{"desktop-over-env", "en-US", "zh", "en", "", "zh"},
|
||||
{"c-locale", "zh-Hans-CN", "auto", "", "C", "en"},
|
||||
{"posix-locale", "zh-Hans-CN", "auto", "", "POSIX.UTF-8", "en"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
t.Chdir(testenv.TempDir(t))
|
||||
for _, key := range []string{"REASONIX_LANG", "LC_ALL", "LC_MESSAGES", "LANG"} {
|
||||
t.Setenv(key, "")
|
||||
}
|
||||
t.Setenv("REASONIX_SYSTEM_LANG", tt.system)
|
||||
t.Setenv("REASONIX_LANG", tt.env)
|
||||
t.Setenv("LANG", tt.locale)
|
||||
cfg := config.Default()
|
||||
disabled := false
|
||||
cfg.Desktop.Telemetry, cfg.Desktop.Metrics = &disabled, &disabled
|
||||
if err := cfg.SetDesktopLanguage(tt.desktop); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
hub, err := assemble(ctx, io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now))
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
defer hub.Shutdown()
|
||||
b, err := bind(hub.Handler())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- b.serve(ctx) }()
|
||||
defer func() {
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Error("host did not drain")
|
||||
}
|
||||
}()
|
||||
check := func(want string) {
|
||||
t.Helper()
|
||||
status, body := ask(t, b, http.MethodGet, "/tray/state", nil)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("tray status = %d", status)
|
||||
}
|
||||
var state serve.TrayState
|
||||
if err := json.Unmarshal([]byte(body), &state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
say := i18n.Catalog(want)
|
||||
labels := serve.TrayLabels{Open: say.TrayOpen, CloseToTray: say.TrayCloseToTray, Quit: say.TrayQuit}
|
||||
if state.Labels != labels || state.Line != say.TrayIdle {
|
||||
t.Fatalf("tray = %+v, want labels %+v and line %q", state, labels, say.TrayIdle)
|
||||
}
|
||||
}
|
||||
check(tt.want)
|
||||
for _, lang := range []string{"en", "zh"} {
|
||||
status, _ := ask(t, b, http.MethodPost, "/appearance", func(r *http.Request) {
|
||||
r.Body = io.NopCloser(strings.NewReader(`{"language":"` + lang + `"}`))
|
||||
})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("appearance status = %d", status)
|
||||
}
|
||||
check(lang)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,13 @@ func TestCLIGateBlocksEveryCLIChannel(t *testing.T) {
|
||||
t.Fatalf("%s does not wait for cli-tag", name)
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, n := range mappingValue(mappingValue(jobs, "cli-channels"), "needs").Content {
|
||||
found = found || n.Value == "cli-pointer"
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("cli-channels does not wait for cli-pointer: the cask would name archives that may not exist yet")
|
||||
}
|
||||
}
|
||||
|
||||
var releaseTagSecrets = []string{"RELEASE_TAG_TOKEN"}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"gopkg.in/yaml.v3"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func stepNamed(t *testing.T, job *yaml.Node, name string) *yaml.Node {
|
||||
t.Helper()
|
||||
steps := mappingValue(job, "steps")
|
||||
for _, step := range steps.Content {
|
||||
if mappingScalar(step, "name") == name {
|
||||
return step
|
||||
}
|
||||
}
|
||||
t.Fatalf("no step %q", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestStudioReleaseDecidesLatestFromTheFlagsScript(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
publish := mappingValue(jobsOf(t, root), "publish")
|
||||
step := stepNamed(t, publish, "Publish GitHub release")
|
||||
run := mappingScalar(step, "run")
|
||||
env := mappingValue(step, "env")
|
||||
if got := mappingScalar(env, "CANDIDATE"); got != "${{ needs.resolve.outputs.candidate }}" {
|
||||
t.Fatalf("CANDIDATE = %q, want the resolve job's grammar-derived output", got)
|
||||
}
|
||||
if !strings.Contains(run, `scripts/studio-release-flags.sh "$TAG" "$CANDIDATE"`) {
|
||||
t.Fatal("the release flags do not come from scripts/studio-release-flags.sh")
|
||||
}
|
||||
for _, flag := range []string{"--prerelease", "--latest"} {
|
||||
if strings.Contains(run, flag) {
|
||||
t.Errorf("the publish step spells %s itself; the decision belongs to the flags script", flag)
|
||||
}
|
||||
}
|
||||
if strings.Contains(run, "gh release edit") && strings.Contains(run[strings.Index(run, "gh release edit"):], "--latest") {
|
||||
t.Error("an existing release must not have its latest flag edited")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStudioGitHubReleaseCarriesNoRootManifest(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
publish := mappingValue(jobsOf(t, root), "publish")
|
||||
run := mappingScalar(stepNamed(t, publish, "Publish GitHub release"), "run")
|
||||
if !strings.Contains(run, `= latest.json ]`) {
|
||||
t.Fatal("the publish step does not filter latest.json out of the uploaded assets")
|
||||
}
|
||||
for line := range strings.SplitSeq(run, "\n") {
|
||||
if strings.Contains(line, "gh release") && strings.Contains(line, "dist/") {
|
||||
t.Errorf("gh uploads the dist glob, root latest.json included: %s", strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
if !strings.Contains(run, `"${assets[@]}"`) {
|
||||
t.Fatal("the release is not created from the filtered asset list")
|
||||
}
|
||||
mirror := mappingScalar(stepNamed(t, publish, "Mirror to R2 and update the Studio catalog"), "run")
|
||||
if !strings.Contains(mirror, `aws s3 cp dist/ "s3://${R2_BUCKET}/${TAG}/" --recursive`) {
|
||||
t.Fatal("the per-tag manifest must still reach R2 with the rest of dist")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var windowsArchitectures = []string{"amd64", "arm64"}
|
||||
|
||||
func matrixArches(t *testing.T, job *yaml.Node) []string {
|
||||
t.Helper()
|
||||
matrix := mappingValue(mappingValue(job, "strategy"), "matrix")
|
||||
if matrix == nil {
|
||||
t.Fatal("job has no matrix")
|
||||
}
|
||||
var arches []string
|
||||
if list := mappingValue(matrix, "arch"); list != nil {
|
||||
for _, n := range list.Content {
|
||||
arches = append(arches, n.Value)
|
||||
}
|
||||
}
|
||||
if include := mappingValue(matrix, "include"); include != nil {
|
||||
for _, entry := range include.Content {
|
||||
if arch := mappingScalar(entry, "arch"); arch != "" {
|
||||
arches = append(arches, arch)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(arches)
|
||||
return arches
|
||||
}
|
||||
|
||||
// One tree per job: each Windows stage runs once for every architecture the
|
||||
// build produces, so no architecture reaches publish without passing through
|
||||
// signing, packaging and verification.
|
||||
func TestEveryWindowsSigningStageCoversEveryArchitecture(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
for _, name := range []string{"windows-sign-payload", "windows-package", "windows-verify-package", "windows-sign-installer", "windows-install-smoke"} {
|
||||
if got := matrixArches(t, mappingValue(jobs, name)); !slices.Equal(got, windowsArchitectures) {
|
||||
t.Errorf("%s covers %v, want %v", name, got, windowsArchitectures)
|
||||
}
|
||||
}
|
||||
build := mappingValue(mappingValue(mappingValue(mappingValue(jobs, "build"), "strategy"), "matrix"), "include")
|
||||
var built []string
|
||||
for _, entry := range build.Content {
|
||||
if goarch := mappingScalar(entry, "goarch"); goarch != "" {
|
||||
built = append(built, goarch)
|
||||
}
|
||||
}
|
||||
slices.Sort(built)
|
||||
if !slices.Equal(built, windowsArchitectures) {
|
||||
t.Errorf("build produces Windows bundles for %v, want %v", built, windowsArchitectures)
|
||||
}
|
||||
}
|
||||
|
||||
// Certum's group holds one running and one waiting job, and a newer waiting job
|
||||
// cancels the older. Legs of one matrix would race for that single slot, so the
|
||||
// signing stages run them strictly one after another.
|
||||
func TestWindowsSigningLegsRunOneAtATime(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
for _, name := range []string{"windows-sign-payload", "windows-sign-installer"} {
|
||||
job := mappingValue(jobs, name)
|
||||
strategy := mappingValue(job, "strategy")
|
||||
if got := mappingScalar(strategy, "max-parallel"); got != "1" {
|
||||
t.Errorf("%s max-parallel = %q, want 1", name, got)
|
||||
}
|
||||
if got := mappingScalar(strategy, "fail-fast"); got != "false" {
|
||||
t.Errorf("%s fail-fast = %q; a failed leg must leave the others to finish so only it reruns", name, got)
|
||||
}
|
||||
group := mappingScalar(mappingValue(job, "concurrency"), "group")
|
||||
if group != "certum-signing" {
|
||||
t.Errorf("%s concurrency group = %q, want certum-signing", name, group)
|
||||
}
|
||||
if mappingScalar(job, "environment") != "studio-release" {
|
||||
t.Errorf("%s lost its studio-release environment", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Packaging, package verification and the install smoke parse archives and run
|
||||
// installers, so none of them may hold an environment or a secret.
|
||||
func TestWindowsPackagingStagesHoldNoSecrets(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
for _, name := range []string{"windows-package", "windows-verify-package", "windows-install-smoke"} {
|
||||
job := mappingValue(jobs, name)
|
||||
if mappingValue(job, "environment") != nil {
|
||||
t.Errorf("%s declares an environment", name)
|
||||
}
|
||||
if nodeContains(job, "secrets.") {
|
||||
t.Errorf("%s references a secret", name)
|
||||
}
|
||||
if nodeContains(job, "id-token") {
|
||||
t.Errorf("%s requests an id-token", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The arm64 installer reads back through the runner's 7-Zip, which decodes
|
||||
// filters the NSIS extractor cannot, so the filter that extractor reads is
|
||||
// pinned on every job that packs one, and publish waits for an installer to run.
|
||||
func TestArm64InstallerIsPackedWithTheFilterTheExtractorReads(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
pack := map[string]string{"build": "Package Windows from the bundle", "windows-package": "Package Windows from the signed bundle"}
|
||||
for job, step := range pack {
|
||||
env := mappingValue(stepNamed(t, mappingValue(jobs, job), step), "env")
|
||||
if got := mappingScalar(env, "ELECTRON_BUILDER_7Z_FILTER"); !strings.Contains(got, "'BCJ'") {
|
||||
t.Errorf("%s / %s: ELECTRON_BUILDER_7Z_FILTER = %q, want BCJ for arm64", job, step, got)
|
||||
}
|
||||
}
|
||||
needs := mappingValue(mappingValue(jobs, "publish"), "needs")
|
||||
var names []string
|
||||
for _, n := range needs.Content {
|
||||
names = append(names, n.Value)
|
||||
}
|
||||
if !slices.Contains(names, "windows-install-smoke") {
|
||||
t.Error("publish does not wait for windows-install-smoke")
|
||||
}
|
||||
if !strings.Contains(mappingScalar(mappingValue(jobs, "publish"), "if"), "needs.windows-install-smoke.result == 'success'") {
|
||||
t.Error("publish does not require windows-install-smoke to have succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// Matrix legs share one output map and the last non-empty write wins, so each
|
||||
// leg writes only its own architecture's key. A key that is not guarded by the
|
||||
// leg's architecture would let one leg's value stand in for the other's.
|
||||
func TestWindowsOutputsAreOnePerArchitecture(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
want := map[string][]string{
|
||||
"windows-sign-payload": {"payload-digest"},
|
||||
"windows-verify-package": {"installer-sha256", "archive-sha256"},
|
||||
"windows-sign-installer": {"installer-sha256"},
|
||||
}
|
||||
for name, keys := range want {
|
||||
outputs := mappingValue(mappingValue(jobs, name), "outputs")
|
||||
if outputs == nil {
|
||||
t.Fatalf("%s has no outputs", name)
|
||||
}
|
||||
if len(outputs.Content)/2 != len(keys)*len(windowsArchitectures) {
|
||||
t.Errorf("%s declares %d outputs, want %d", name, len(outputs.Content)/2, len(keys)*len(windowsArchitectures))
|
||||
}
|
||||
for _, key := range keys {
|
||||
for _, arch := range windowsArchitectures {
|
||||
got := mappingScalar(outputs, key+"-"+arch)
|
||||
if !strings.Contains(got, "matrix.arch == '"+arch+"'") {
|
||||
t.Errorf("%s output %s-%s = %q, want it guarded by matrix.arch == '%s'", name, key, arch, got, arch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The install smoke gates publish, so it has to run exactly when a Windows
|
||||
// package exists to run: after a signed installer, or after the build when
|
||||
// signing is off, and never after a failed signing chain.
|
||||
func TestInstallSmokeRunsOnlyWhereAPackageExists(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
smoke := mappingValue(jobs, "windows-install-smoke")
|
||||
cond := mappingScalar(smoke, "if")
|
||||
for _, want := range []string{
|
||||
"!cancelled()",
|
||||
"needs.build.result == 'success'",
|
||||
"needs.windows-sign-installer.result == 'success'",
|
||||
"vars.STUDIO_SIGNING_ENABLED != 'true'",
|
||||
"needs.windows-sign-installer.result == 'skipped'",
|
||||
} {
|
||||
if !strings.Contains(cond, want) {
|
||||
t.Errorf("windows-install-smoke if lacks %q: %s", want, cond)
|
||||
}
|
||||
}
|
||||
run := mappingScalar(stepNamed(t, smoke, "Install, check and uninstall"), "run")
|
||||
if !strings.Contains(run, "./scripts/install-smoke.ps1") {
|
||||
t.Error("the smoke job does not run scripts/install-smoke.ps1, the script pull requests run")
|
||||
}
|
||||
studio := parseWorkflowFile(t, "../../.github/workflows/studio.yml")
|
||||
pkg := mappingValue(jobsOf(t, studio), "package")
|
||||
if run := mappingScalar(stepNamed(t, pkg, "The package installs, runs and uninstalls"), "run"); !strings.Contains(run, "./scripts/install-smoke.ps1") {
|
||||
t.Error("the Studio package job does not run scripts/install-smoke.ps1, so pull requests would not exercise the release smoke")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishRefusesAPartialWindowsSet(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
publish := mappingValue(jobsOf(t, root), "publish")
|
||||
run := mappingScalar(stepNamed(t, publish, "Collect artifacts"), "run")
|
||||
if !strings.Contains(run, `"$count" -lt 9`) {
|
||||
t.Error("publish does not require at least 9 ReasonixStudio-* packages")
|
||||
}
|
||||
for _, name := range []string{
|
||||
"ReasonixStudio-windows-amd64-installer.exe", "ReasonixStudio-windows-arm64-installer.exe",
|
||||
"ReasonixStudio-windows-amd64.zip", "ReasonixStudio-windows-arm64.zip",
|
||||
} {
|
||||
if !strings.Contains(run, "'"+name+"'") {
|
||||
t.Errorf("publish does not require %s by name", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,3 +189,46 @@ func TestUnmirroredManifestNamesOnlyGitHub(t *testing.T) {
|
||||
t.Fatalf("asset = %+v, want GitHub alone when nothing is mirrored", a)
|
||||
}
|
||||
}
|
||||
|
||||
// Both Windows architectures ship an installer and a portable archive under
|
||||
// the same naming rule; each installer answers only its own platform key.
|
||||
func TestWindowsArchitecturesResolveToTheirOwnInstallers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
for _, name := range []string{
|
||||
"ReasonixStudio-windows-amd64-installer.exe",
|
||||
"ReasonixStudio-windows-amd64.zip",
|
||||
"ReasonixStudio-windows-arm64-installer.exe",
|
||||
"ReasonixStudio-windows-arm64.zip",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := run(dir, "v0.1.0", "studio-v0.1.0", "", false); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
var m update.Manifest
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "latest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.Platforms) != 2 || len(m.Downloads) != 2 {
|
||||
t.Fatalf("platforms %d, downloads %d; want one installer per architecture", len(m.Platforms), len(m.Downloads))
|
||||
}
|
||||
for arch, name := range map[string]string{
|
||||
"amd64": "ReasonixStudio-windows-amd64-installer.exe",
|
||||
"arm64": "ReasonixStudio-windows-arm64-installer.exe",
|
||||
} {
|
||||
a, ok := m.Platforms[update.PlatformKey("windows", arch)]
|
||||
if !ok || !strings.HasSuffix(a.URL, "/"+name) {
|
||||
t.Errorf("windows-%s resolves to %+v, want %s", arch, a, name)
|
||||
}
|
||||
}
|
||||
if len(m.Deltas) != 0 {
|
||||
t.Errorf("no delta directory was given, yet deltas = %v", m.Deltas)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 36 KiB |
@@ -99,6 +99,17 @@
|
||||
# full of someone's work gets named in the first place. Refuse with an exit
|
||||
# code, because that is all a script driving this can read.
|
||||
!macro customInit
|
||||
# The arm64 installer carries only an arm64 application: on any other
|
||||
# machine electron-builder's extraction picks no archive and installs nothing
|
||||
# while reporting success.
|
||||
!ifdef APP_ARM64
|
||||
${IfNot} ${IsNativeARM64}
|
||||
DetailPrint "This installer is for Windows on ARM64 and cannot run on this machine"
|
||||
MessageBox MB_ICONSTOP "This Reasonix Studio installer is built for Windows on ARM64.$\r$\n$\r$\nDownload the x64 installer for this machine." /SD IDOK
|
||||
SetErrorLevel 3
|
||||
Abort
|
||||
${EndIf}
|
||||
!endif
|
||||
Push $R9
|
||||
!insertmacro instDirTakesNothingElse $R9
|
||||
${If} $R9 == 0
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"computer-live": "electron test/computer_live.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "44.4.5",
|
||||
"electron": "44.5.1",
|
||||
"electron-builder": "^26.15.3",
|
||||
"playwright": "1.63.0"
|
||||
},
|
||||
|
||||
@@ -45,22 +45,35 @@ function go(args, env) {
|
||||
// statically so the helper needs no redistributable on the person's machine.
|
||||
const VS_INSTALLER = path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "Microsoft Visual Studio", "Installer");
|
||||
|
||||
function msvcVars() {
|
||||
// The helper is built for the architecture the kernel is, with that
|
||||
// architecture's own tools: the x64 toolset on an x64 machine, the ARM64 one on
|
||||
// an ARM64 machine (vcvarsarm64 is the native environment there).
|
||||
const WINDOWS_TOOLCHAIN = {
|
||||
amd64: { component: "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", vars: "vcvars64.bat", label: "x64" },
|
||||
arm64: { component: "Microsoft.VisualStudio.Component.VC.Tools.ARM64", vars: "vcvarsarm64.bat", label: "ARM64" },
|
||||
};
|
||||
|
||||
function msvcVars(toolchain) {
|
||||
const vswhere = path.join(VS_INSTALLER, "vswhere.exe");
|
||||
if (!fs.existsSync(vswhere)) return "";
|
||||
const found = spawnSync(vswhere, ["-latest", "-products", "*", "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", "-property", "installationPath"], { encoding: "utf8" });
|
||||
const found = spawnSync(vswhere, ["-latest", "-products", "*", "-requires", toolchain.component, "-property", "installationPath"], { encoding: "utf8" });
|
||||
const root = (found.stdout || "").trim().split(/\r?\n/)[0];
|
||||
const vars = root ? path.join(root, "VC", "Auxiliary", "Build", "vcvars64.bat") : "";
|
||||
const vars = root ? path.join(root, "VC", "Auxiliary", "Build", toolchain.vars) : "";
|
||||
return vars && fs.existsSync(vars) ? vars : "";
|
||||
}
|
||||
|
||||
function buildWindowsHelper() {
|
||||
const vars = msvcVars();
|
||||
function buildWindowsHelper(goarch) {
|
||||
const toolchain = WINDOWS_TOOLCHAIN[goarch];
|
||||
if (!toolchain) {
|
||||
console.error(`no MSVC toolchain is declared for windows/${goarch}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const vars = msvcVars(toolchain);
|
||||
if (!vars) {
|
||||
// A release without the helper would ship computer use switched off with
|
||||
// nothing saying why, so CI refuses; a dev build only loses the feature.
|
||||
if (process.env.CI) {
|
||||
console.error("MSVC with the x64 C++ tools is required to build reasonix-computer-helper.exe");
|
||||
console.error(`MSVC with the ${toolchain.label} C++ tools is required to build reasonix-computer-helper.exe`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.warn("MSVC not found: building without reasonix-computer-helper.exe, so computer use is unavailable in this build");
|
||||
@@ -91,9 +104,12 @@ function buildWindowsHelper() {
|
||||
if (process.platform !== "darwin") {
|
||||
// go build names the binary after the package and adds .exe where it belongs.
|
||||
// Without cgo, because with it the Linux kernel inherits the runner's glibc.
|
||||
go(["build", "-ldflags", LDFLAGS, "-o", OUT + path.sep, PKG], { CGO_ENABLED: "0" });
|
||||
// REASONIX_GOARCH names the architecture to build for; unset, Go's own is
|
||||
// used, which is the runner's.
|
||||
const goarch = (process.env.REASONIX_GOARCH || "").trim();
|
||||
go(["build", "-ldflags", LDFLAGS, "-o", OUT + path.sep, PKG], goarch ? { CGO_ENABLED: "0", GOARCH: goarch } : { CGO_ENABLED: "0" });
|
||||
console.log("built reasonix-studio-host");
|
||||
if (process.platform === "win32") buildWindowsHelper();
|
||||
if (process.platform === "win32") buildWindowsHelper(goarch || (process.arch === "arm64" ? "arm64" : "amd64"));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,11 @@ const ICO_SIZES = [16, 20, 24, 32, 40, 48, 64, 256];
|
||||
const TRAY_SCALES = [[1, ""], [1.25, "@1.25x"], [1.5, "@1.5x"], [1.75, "@1.75x"], [2, "@2x"], [2.5, "@2.5x"], [3, "@3x"]];
|
||||
// The app icon's margin is the platform's to keep; a tray slot has none to spare.
|
||||
const TRAY_VIEWBOX = 'viewBox="103 103 818 818"';
|
||||
// Windows draws the .ico edge to edge beside neighbours that fill their cell, so
|
||||
// its tile is cropped to a 2% margin and the glyph enlarged within it; macOS and
|
||||
// Linux keep the full-canvas artwork the platform templates expect.
|
||||
const WIN_VIEWBOX = 'viewBox="91 91 842 842"';
|
||||
const WIN_GLYPH_SCALE = "scale(2.4)";
|
||||
// PNG payloads for every type modern macOS reads; ic11–ic14 are the @2x slots.
|
||||
const ICNS_TYPES = [
|
||||
["ic04", 16], ["ic05", 32], ["ic07", 128], ["ic08", 256], ["ic09", 512], ["ic10", 1024],
|
||||
@@ -74,8 +79,9 @@ async function main() {
|
||||
for (const [scale, suffix] of TRAY_SCALES) {
|
||||
fs.writeFileSync(path.join(ASSETS, `tray${suffix}.png`), await render(page, traySvg, 16 * scale));
|
||||
}
|
||||
const winSvg = svg.replace('viewBox="0 0 1024 1024"', WIN_VIEWBOX).replace("scale(1.9)", WIN_GLYPH_SCALE);
|
||||
const icoPngs = [];
|
||||
for (const size of ICO_SIZES) icoPngs.push([size, await at(size)]);
|
||||
for (const size of ICO_SIZES) icoPngs.push([size, await render(page, winSvg, size)]);
|
||||
fs.writeFileSync(path.join(ASSETS, "icon.ico"), ico(icoPngs));
|
||||
const icnsPngs = [];
|
||||
for (const [type, size] of ICNS_TYPES) icnsPngs.push([type, await at(size)]);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"use strict";
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const MACHINE = { amd64: 0x8664, arm64: 0xaa64 };
|
||||
// Written by the NSIS installer rather than built by us, and 32-bit on every
|
||||
// architecture; the release pins it by content (windows-signing-lib.ps1).
|
||||
const INSTALLER_OWNED = { "resources/elevate.exe": 0x14c, "Uninstall Reasonix Studio.exe": 0x14c };
|
||||
const EXTENSIONS = new Set([".exe", ".dll", ".node"]);
|
||||
|
||||
// The machine field of a PE image's COFF header. An emulated x64 binary and a
|
||||
// native one are both valid programs on ARM64 Windows, so the architecture a
|
||||
// package claims has to be read from the files themselves.
|
||||
function peMachine(bytes) {
|
||||
if (bytes.length < 0x40 || bytes.readUInt16LE(0) !== 0x5a4d) throw new Error("not a PE image");
|
||||
const header = bytes.readUInt32LE(0x3c);
|
||||
if (header + 6 > bytes.length || bytes.readUInt32LE(header) !== 0x00004550) throw new Error("no PE signature");
|
||||
return bytes.readUInt16LE(header + 4);
|
||||
}
|
||||
|
||||
function isPeCandidate(file) {
|
||||
if (EXTENSIONS.has(path.extname(file).toLowerCase())) return true;
|
||||
const fd = fs.openSync(file, "r");
|
||||
try {
|
||||
const head = Buffer.alloc(2);
|
||||
return fs.readSync(fd, head, 0, 2, 0) === 2 && head.readUInt16LE(0) === 0x5a4d;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function walk(dir, into) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, into);
|
||||
else if (entry.isFile() && isPeCandidate(full)) into.push(full);
|
||||
}
|
||||
return into;
|
||||
}
|
||||
|
||||
// Every PE image under root with the machine it was built for.
|
||||
function machines(root) {
|
||||
return walk(root, []).sort().map((file) => {
|
||||
const name = path.relative(root, file).split(path.sep).join("/");
|
||||
try {
|
||||
return { file: name, machine: peMachine(fs.readFileSync(file)) };
|
||||
} catch (err) {
|
||||
return { file: name, machine: null, error: err.message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The images under root that are not built for arch; none means the tree is
|
||||
// uniformly that architecture. An empty tree is refused, not passed.
|
||||
function foreign(root, arch) {
|
||||
const want = MACHINE[arch];
|
||||
if (want === undefined) throw new Error(`unknown architecture ${arch}`);
|
||||
const found = machines(root);
|
||||
if (found.length === 0) throw new Error(`no PE images under ${root}`);
|
||||
return found.filter((f) => f.machine !== (INSTALLER_OWNED[f.file] ?? want));
|
||||
}
|
||||
|
||||
module.exports = { peMachine, machines, foreign, MACHINE };
|
||||
|
||||
if (require.main === module) {
|
||||
const [root, arch] = process.argv.slice(2);
|
||||
if (!root || !arch) {
|
||||
console.error("usage: node packaging/pe.js <dir> <amd64|arm64>");
|
||||
process.exit(2);
|
||||
}
|
||||
for (const f of machines(root)) console.log(`${f.machine === null ? f.error : "0x" + f.machine.toString(16)} ${f.file}`);
|
||||
const wrong = foreign(root, arch);
|
||||
for (const f of wrong) {
|
||||
console.error(f.error ? `::error::${f.file} cannot be read as a PE image: ${f.error}` : `::error::${f.file} is machine 0x${f.machine.toString(16)}, not ${arch}`);
|
||||
}
|
||||
process.exit(wrong.length === 0 ? 0 : 1);
|
||||
}
|
||||
Generated
+5
-5
@@ -9,8 +9,8 @@ importers:
|
||||
.:
|
||||
devDependencies:
|
||||
electron:
|
||||
specifier: 44.4.5
|
||||
version: 44.4.5([email protected])
|
||||
specifier: 44.5.1
|
||||
version: 44.5.1([email protected])
|
||||
electron-builder:
|
||||
specifier: ^26.15.3
|
||||
version: 26.15.3([email protected])([email protected])
|
||||
@@ -374,8 +374,8 @@ packages:
|
||||
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
electron@44.4.5:
|
||||
resolution: {integrity: sha512-SjgoaeYsSWZfJzubgQU7juvuXMTvn6/e1gAHdGFA/yuMbpF+I+skYqIJ6DdXBXHeWbkbpNpmwZCTpiivtlWZSw==}
|
||||
electron@44.5.1:
|
||||
resolution: {integrity: sha512-lx7AYoFIiyvEvgvpyWO9kOC60xtNPpiPZAth6Fmg9NutzpCAMLwpUlaBY2EXfZyIgUdzEmLMj9KvK0Vrokl1yQ==}
|
||||
engines: {node: '>= 22.12.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1551,7 +1551,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
electron@44.4.5([email protected]):
|
||||
electron@44.5.1([email protected]):
|
||||
dependencies:
|
||||
'@electron-internal/extract-zip': 1.0.5
|
||||
'@electron/get': 5.1.0([email protected])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use strict";
|
||||
const { localPath, trimTyped } = require("./localpath");
|
||||
|
||||
// What a page in the agent's browser may become. The agent's own requests pass
|
||||
// the kernel's policy first; this is the window's answer for everything else a
|
||||
@@ -16,13 +17,16 @@ function guestNavigationAllowed(raw, kernelOrigin) {
|
||||
}
|
||||
|
||||
// What the person typed, as the address to load and the one to fall back to.
|
||||
// Only "scheme://" or about: names a scheme: "intranet:8080" is a host and a
|
||||
// A path on disk is the file it names; see localPath. Only "scheme://", file:
|
||||
// or about: names a scheme: "intranet:8080" is a host and a
|
||||
// port. A host that cannot be on the public internet is read as http, the way
|
||||
// Chromium exempts it from upgrading; any other bare host tries https first
|
||||
// and falls back to http, since an intranet site may serve only http.
|
||||
function typedAddress(raw) {
|
||||
const text = String(raw || "").trim();
|
||||
const text = trimTyped(raw);
|
||||
if (!text) return { url: "", fallback: "" };
|
||||
const local = localPath(text);
|
||||
if (local) return { url: local.url, fallback: "" };
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text) || /^about:/i.test(text)) return { url: text, fallback: "" };
|
||||
let host;
|
||||
try {
|
||||
@@ -34,6 +38,24 @@ function typedAddress(raw) {
|
||||
return { url: "https://" + text, fallback: "http://" + text };
|
||||
}
|
||||
|
||||
// typed is the whole answer for what the person typed: the address, its
|
||||
// fallback, and "" or why the window will not load it. A page's own links never
|
||||
// reach a file (guestNavigationAllowed); the person may, but only on this
|
||||
// machine: a share is refused, and Windows would sign in to its host. The
|
||||
// kernel still refuses to read or act on a page outside the workspace, whoever
|
||||
// opened it.
|
||||
function typed(raw, kernelOrigin) {
|
||||
const local = localPath(raw);
|
||||
if (local?.kind === "network") return { url: "", fallback: "", refusal: "network_file" };
|
||||
if (local?.kind === "invalid") return { url: "", fallback: "", refusal: "scheme" };
|
||||
const { url, fallback } = typedAddress(raw);
|
||||
return { url, fallback, refusal: url.startsWith("file:") ? "" : webRefusal(url, kernelOrigin) };
|
||||
}
|
||||
|
||||
function webRefusal(url, kernelOrigin) {
|
||||
return guestNavigationAllowed(url, kernelOrigin) ? "" : "scheme";
|
||||
}
|
||||
|
||||
// privateHost reads the address's structure, never its spelling: a loopback,
|
||||
// private, link-local or shared IP literal, localhost, or a name with no dot,
|
||||
// which no public resolver answers for.
|
||||
@@ -58,4 +80,4 @@ function privateHost(host) {
|
||||
return h === "localhost" || h.endsWith(".localhost") || !h.includes(".");
|
||||
}
|
||||
|
||||
module.exports = { guestNavigationAllowed, typedAddress, privateHost };
|
||||
module.exports = { guestNavigationAllowed, typedAddress, typed, privateHost };
|
||||
@@ -1,7 +1,8 @@
|
||||
"use strict";
|
||||
const crypto = require("node:crypto");
|
||||
const { WebContentsView, session } = require("electron");
|
||||
const { guestNavigationAllowed, typedAddress } = require("./browserguard");
|
||||
const { installPaneReload } = require("./reload");
|
||||
const { guestNavigationAllowed, typed } = require("./browserguard");
|
||||
|
||||
// The size a page lays out at while nobody is looking at it: the kernel drives
|
||||
// pages whether or not the panel is open, and a page sized to nothing reads as
|
||||
@@ -96,6 +97,7 @@ class BrowserViews {
|
||||
if (allowed(to)) onPopup(to);
|
||||
return { action: "deny" };
|
||||
});
|
||||
installPaneReload(contents);
|
||||
contents.debugger.attach("1.3");
|
||||
contents.debugger.on("message", (_event, method, params, sessionId) => {
|
||||
if (sessionId) return;
|
||||
@@ -219,12 +221,15 @@ class BrowserViews {
|
||||
}
|
||||
}
|
||||
|
||||
// navigate starts loading what the person typed and answers "", or the code
|
||||
// of why the window will not: "scheme" or "network_file".
|
||||
navigate(targetId, address) {
|
||||
const entry = this.entries.get(targetId);
|
||||
const { url, fallback } = typedAddress(address);
|
||||
if (!entry || !guestNavigationAllowed(url, this.kernelOrigin)) return false;
|
||||
const { url, fallback, refusal } = typed(address, this.kernelOrigin);
|
||||
if (!entry) return "scheme";
|
||||
if (refusal) return refusal;
|
||||
void this.loadTyped(targetId, entry, url, fallback);
|
||||
return true;
|
||||
return "";
|
||||
}
|
||||
|
||||
// loadTyped owns the outcome of an address the person typed. loadURL's own
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"use strict";
|
||||
|
||||
// The page's own `--page` ground for each scheme (frontend-next tokens.css),
|
||||
// as sRGB. A window paints this wherever the page does not, so a pixel the
|
||||
// compositor leaves uncovered at a fractional scale matches the app.
|
||||
const GROUND = { light: "#f4f1ea", dark: "#060a10" };
|
||||
|
||||
// "auto" and anything unrecognised follow the system, as the page does.
|
||||
function groundFor(prefs, systemDark) {
|
||||
const theme = prefs?.["rx-theme"];
|
||||
const dark = theme === "dark" ? true : theme === "light" ? false : systemDark;
|
||||
return dark ? GROUND.dark : GROUND.light;
|
||||
}
|
||||
|
||||
module.exports = { GROUND, groundFor };
|
||||
@@ -25,8 +25,10 @@ class HostStartError extends Error {
|
||||
// the only channel pointing this way. stdin is the lease: the kernel drains
|
||||
// when this end closes, which is what stops it outliving a parent that exited
|
||||
// without being asked to.
|
||||
function start(binary, args, { onStderr, onExit, onAct, onSlow, timeoutMs = HANDSHAKE_TIMEOUT_MS } = {}) {
|
||||
const child = spawn(binary, args, { stdio: ["pipe", "pipe", "pipe"] });
|
||||
function start(binary, args, { onStderr, onExit, onAct, onSlow, systemLanguage, timeoutMs = HANDSHAKE_TIMEOUT_MS } = {}) {
|
||||
const env = { ...process.env };
|
||||
if (systemLanguage) env.REASONIX_SYSTEM_LANG = systemLanguage;
|
||||
const child = spawn(binary, args, { stdio: ["pipe", "pipe", "pipe"], env });
|
||||
child.stderr.setEncoding("utf8");
|
||||
if (onStderr) child.stderr.on("data", onStderr);
|
||||
if (onExit) child.on("exit", onExit);
|
||||
|
||||
@@ -26,6 +26,10 @@ class StudioHost {
|
||||
return this.json("GET", "/tray/state");
|
||||
}
|
||||
|
||||
trayRunning() {
|
||||
return this.json("GET", "/tray/running");
|
||||
}
|
||||
|
||||
// A refusal and an unreachable kernel both answer null here: every caller of
|
||||
// this is a surface that has to keep working when the kernel is going down.
|
||||
async json(method, path, body) {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"use strict";
|
||||
|
||||
// What a typed address is when it names a place on disk. Only a form that cannot
|
||||
// be a host claims it: a drive letter and a separator, a leading "\\" (also
|
||||
// "\\?\" and "\\.\"), one leading slash, or a file: URL. "D:foo", "c:",
|
||||
// "host:8080" and "//host/x" are not paths. A file: URL for another host, or one
|
||||
// whose path opens with a second slash once escapes and dot segments resolve,
|
||||
// is a share: Windows reads //host/... as one. internal/platform/browser/
|
||||
// localpath.go reads the same table (testdata/local_paths.json).
|
||||
const SPACE = "\\t-\\r \\u0085\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff";
|
||||
const TRIM = new RegExp(`^[${SPACE}]+|[${SPACE}]+$`, "g");
|
||||
|
||||
function trimTyped(raw) {
|
||||
return String(raw ?? "").replace(TRIM, "");
|
||||
}
|
||||
|
||||
function localPath(raw) {
|
||||
const text = trimTyped(raw).toWellFormed();
|
||||
const flat = text.replace(/[\t\n\r]/g, "");
|
||||
if (/^file:\//i.test(flat)) return fileForm(flat.slice(5));
|
||||
if (text.startsWith("\\\\?\\") || text.startsWith("\\\\.\\")) return deviceForm(text.slice(4));
|
||||
const drive = driveSplit(text);
|
||||
if (drive) return { url: `file:///${drive[0]}:/${segments(drive[1], /[\\/]/)}`, kind: "local" };
|
||||
if (text.startsWith("\\\\")) return uncForm(text.slice(2));
|
||||
if (text.startsWith("/") && !text.startsWith("//")) {
|
||||
return { url: `file:///${segments(text.slice(1), /\//)}`, kind: "local" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function driveSplit(text) {
|
||||
const m = /^\/?([A-Za-z]):[\\/](.*)$/s.exec(text);
|
||||
return m ? [m[1], m[2]] : null;
|
||||
}
|
||||
|
||||
function uncForm(rest) {
|
||||
const cut = rest.search(/[\\/]/);
|
||||
const host = cut < 0 ? rest : rest.slice(0, cut);
|
||||
if (!host) return null;
|
||||
const share = cut < 0 ? "" : rest.slice(cut + 1);
|
||||
return { url: `file://${encodeURIComponent(host)}/${segments(share, /[\\/]/)}`, kind: "network" };
|
||||
}
|
||||
|
||||
function deviceForm(rest) {
|
||||
if (/^unc[\\/]/i.test(rest)) return uncForm(rest.slice(4));
|
||||
const drive = driveSplit(rest);
|
||||
if (drive) return { url: `file:///${drive[0]}:/${segments(drive[1], /[\\/]/)}`, kind: "local" };
|
||||
return { url: "", kind: "network" };
|
||||
}
|
||||
|
||||
function fileForm(afterScheme) {
|
||||
if (/[\u0000-\u001f\u007f]/.test(afterScheme)) return { url: "", kind: "invalid" };
|
||||
let rest = afterScheme.replaceAll("\\", "/");
|
||||
let host = "";
|
||||
if (rest.startsWith("//")) {
|
||||
const cut = rest.indexOf("/", 2);
|
||||
host = cut < 0 ? rest.slice(2) : rest.slice(2, cut);
|
||||
rest = cut < 0 ? "/" : rest.slice(cut);
|
||||
}
|
||||
const tailAt = rest.search(/[?#]/);
|
||||
let path = tailAt < 0 ? rest : rest.slice(0, tailAt);
|
||||
const tail = tailAt < 0 ? "" : rest.slice(tailAt);
|
||||
if (/^[A-Za-z]:$/.test(host)) path = "/" + host + path;
|
||||
else if (host !== "" && host.toLowerCase() !== "localhost") return { url: "", kind: "network" };
|
||||
if (path === "") path = "/";
|
||||
if (opensShare(path)) return { url: "", kind: "network" };
|
||||
return { url: "file://" + path + tail, kind: "local" };
|
||||
}
|
||||
|
||||
function opensShare(path) {
|
||||
const out = [];
|
||||
for (const seg of path.replace(/%5c|%2f/gi, "/").replace(/%2e/gi, ".").split("/").slice(1)) {
|
||||
if (seg === ".") continue;
|
||||
if (seg === "..") out.pop();
|
||||
else out.push(seg);
|
||||
}
|
||||
return out.length > 1 && out[0] === "";
|
||||
}
|
||||
|
||||
function segments(rest, sep) {
|
||||
const parts = rest.split(sep).filter(Boolean).map(encodeURIComponent);
|
||||
const joined = parts.join("/");
|
||||
return parts.length && sep.test(rest.slice(-1)) ? joined + "/" : joined;
|
||||
}
|
||||
|
||||
module.exports = { localPath, trimTyped };
|
||||
@@ -1,5 +1,5 @@
|
||||
"use strict";
|
||||
const { app, BrowserWindow, dialog, ipcMain, screen, session, shell } = require("electron");
|
||||
const { app, BrowserWindow, dialog, ipcMain, nativeTheme, powerSaveBlocker, screen, session, shell } = require("electron");
|
||||
const { relaunchForOzonePlatform } = require("./ozone");
|
||||
|
||||
// Before the instance lock: this process must hold nothing its relaunch needs.
|
||||
@@ -25,7 +25,9 @@ const { stripPackageGrants, unpaintedWindowCause } = require("./packagegrants");
|
||||
const { BrowserProtocol } = require("./browserprotocol");
|
||||
const { BrowserViews } = require("./browserviews");
|
||||
const { startBrowserRelay } = require("./browserrelay");
|
||||
const { groundFor } = require("./ground");
|
||||
const { loadPrefs, prefsFile, registerPrefs } = require("./prefs");
|
||||
const { createPowerGuard, keepsAwake } = require("./powerguard");
|
||||
const { openLogs, redactArgv, failStartup } = require("./shelllog");
|
||||
|
||||
// A page in a minimized or fully covered window counts as hidden, and a hidden
|
||||
@@ -70,6 +72,7 @@ let grants = null;
|
||||
let browserViews = null;
|
||||
let browserRelay = null;
|
||||
let reload = null;
|
||||
let powerGuard = null;
|
||||
let logs = null;
|
||||
let handshaken = false;
|
||||
|
||||
@@ -94,6 +97,7 @@ async function launchKernel(args) {
|
||||
const began = Date.now();
|
||||
logs.shell.line(`host: starting ${hostBinary} (attempt ${attempt})`);
|
||||
kernel = start(hostBinary, args, {
|
||||
systemLanguage: app.getPreferredSystemLanguages()[0] ?? app.getLocale(),
|
||||
timeoutMs: handshakeTimeout(),
|
||||
onSlow: () => {
|
||||
logs.shell.line("host: no handshake yet; showing the starting window");
|
||||
@@ -115,6 +119,7 @@ async function launchKernel(args) {
|
||||
logs.shell.line(`host: exited code=${code} signal=${signal}${handshaken ? "" : " before its handshake"}`);
|
||||
// Before the handshake the launch itself fails, and boot's catch owns
|
||||
// telling the person why; quitting here would race that dialog.
|
||||
powerGuard?.close();
|
||||
if (handshaken && code !== 0 && !quitting) app.quit();
|
||||
},
|
||||
onAct: handOver,
|
||||
@@ -159,6 +164,17 @@ async function boot() {
|
||||
origin = ready.origin;
|
||||
client = new StudioHost(ready.origin, ready.token);
|
||||
await armCredential(ready);
|
||||
powerGuard = createPowerGuard({
|
||||
blocker: powerSaveBlocker,
|
||||
running: () => client.trayRunning(),
|
||||
enabled: () => keepsAwake(loadPrefs(prefsFile(app.getPath("userData")))),
|
||||
});
|
||||
powerGuard.begin();
|
||||
// The poll alone leaves a turn unprotected for up to its period; the page's
|
||||
// own submit is the moment one starts, and this process sees it complete.
|
||||
session.defaultSession.webRequest.onCompleted({ urls: [`${ready.origin}/*submit`] }, (details) => {
|
||||
if (details.method === "POST" && details.statusCode < 300) void powerGuard?.refresh();
|
||||
});
|
||||
win = createWindow();
|
||||
closeStarting();
|
||||
guard(win.webContents);
|
||||
@@ -270,6 +286,8 @@ function fitted() {
|
||||
};
|
||||
}
|
||||
|
||||
const ground = () => groundFor(loadPrefs(prefsFile(app.getPath("userData"))), nativeTheme.shouldUseDarkColors);
|
||||
|
||||
function createWindow() {
|
||||
const mac = process.platform === "darwin";
|
||||
const windows = process.platform === "win32";
|
||||
@@ -282,6 +300,7 @@ function createWindow() {
|
||||
// Shown once it has been measured against the screen it landed on; sizing a
|
||||
// visible window makes the correction a flicker.
|
||||
show: false,
|
||||
backgroundColor: ground(),
|
||||
frame: !windows,
|
||||
titleBarStyle: mac ? "hiddenInset" : "default",
|
||||
...(mac ? { trafficLightPosition: LIGHTS } : {}),
|
||||
@@ -318,7 +337,14 @@ function fromWindow(event) {
|
||||
// The page reads navigator.languages[0], which is the first preferred system
|
||||
// language; app.getLocale() is the locale Chromium resolved, which can differ.
|
||||
const uiLang = () => uiLanguage(loadPrefs(prefsFile(app.getPath("userData"))), app.getPreferredSystemLanguages()[0] ?? app.getLocale());
|
||||
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow, () => installApplicationMenu(uiLang));
|
||||
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow, () => {
|
||||
installApplicationMenu(uiLang);
|
||||
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
|
||||
void powerGuard?.refresh();
|
||||
});
|
||||
nativeTheme.on("updated", () => {
|
||||
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
|
||||
});
|
||||
|
||||
ipcMain.handle("window:minimise", (event) => {
|
||||
fromWindow(event)?.minimize();
|
||||
@@ -344,7 +370,7 @@ ipcMain.handle("browser:control", (event, targetId, action) => {
|
||||
if (fromWindow(event)) browserViews?.control(String(targetId), String(action));
|
||||
});
|
||||
ipcMain.handle("browser:navigate", (event, targetId, address) =>
|
||||
fromWindow(event) ? (browserViews?.navigate(String(targetId), String(address)) ?? false) : false,
|
||||
fromWindow(event) ? (browserViews?.navigate(String(targetId), String(address)) ?? "scheme") : "scheme",
|
||||
);
|
||||
ipcMain.handle("browser:trust-certificate", (event, targetId) =>
|
||||
fromWindow(event) ? (browserViews?.trustCertificate(String(targetId)) ?? false) : false,
|
||||
@@ -497,6 +523,7 @@ app.on("before-quit", () => {
|
||||
quitting = true;
|
||||
logs.shell.line("shell: quitting");
|
||||
browserRelay?.stop();
|
||||
powerGuard?.close();
|
||||
tray?.close();
|
||||
kernel?.child.stdin.end();
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
"use strict";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
const KIND = "prevent-app-suspension";
|
||||
const PREF_KEY = "rx-keep-awake";
|
||||
// Consecutive idle answers before the block is let go: about fifteen seconds,
|
||||
// so a turn that ends and another that starts do not flap it.
|
||||
const IDLE_PASSES = 3;
|
||||
// Consecutive passes with no usable answer before the block is let go anyway:
|
||||
// about a minute, so a kernel that never answers again cannot hold it forever.
|
||||
const SILENT_PASSES = 12;
|
||||
|
||||
// The page stores the preference as "off" or "on"; anything else is the default.
|
||||
function keepsAwake(prefs) {
|
||||
return prefs?.[PREF_KEY] !== "off";
|
||||
}
|
||||
|
||||
// createPowerGuard holds one system-suspension block while the kernel reports a
|
||||
// turn in flight. running resolves to { panes, unknown } or rejects/returns
|
||||
// null when the kernel itself cannot answer. A pane count above zero takes the
|
||||
// block at once; letting go needs IDLE_PASSES clean zeros in a row, and a pass
|
||||
// that could not be answered neither takes it nor counts toward letting go.
|
||||
function createPowerGuard({ blocker, running, enabled, setInterval: every = setInterval, clearInterval: stop = clearInterval }) {
|
||||
let held = null;
|
||||
let pass = 0;
|
||||
let timer = null;
|
||||
let closed = false;
|
||||
let idle = 0;
|
||||
let silent = 0;
|
||||
|
||||
const hold = () => {
|
||||
if (held !== null && !blocker.isStarted(held)) held = null;
|
||||
if (held === null) held = blocker.start(KIND);
|
||||
};
|
||||
const release = () => {
|
||||
idle = 0;
|
||||
silent = 0;
|
||||
if (held === null) return;
|
||||
if (blocker.isStarted(held)) blocker.stop(held);
|
||||
held = null;
|
||||
};
|
||||
|
||||
async function refresh() {
|
||||
if (closed) return;
|
||||
const mine = ++pass;
|
||||
if (!enabled()) {
|
||||
release();
|
||||
return;
|
||||
}
|
||||
let answer = null;
|
||||
try {
|
||||
answer = await running();
|
||||
} catch {
|
||||
answer = null;
|
||||
}
|
||||
if (mine !== pass || closed) return;
|
||||
const panes = typeof answer?.panes === "number" ? answer.panes : null;
|
||||
const unknown = Number(answer?.unknown) > 0;
|
||||
if (panes !== null && panes > 0) {
|
||||
idle = 0;
|
||||
silent = 0;
|
||||
hold();
|
||||
} else if (panes === 0 && !unknown) {
|
||||
silent = 0;
|
||||
if (held === null || ++idle >= IDLE_PASSES) release();
|
||||
} else if (held !== null && ++silent >= SILENT_PASSES) {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
refresh,
|
||||
held: () => held !== null,
|
||||
begin() {
|
||||
if (timer) return;
|
||||
timer = every(() => void refresh(), POLL_MS);
|
||||
timer.unref?.();
|
||||
void refresh();
|
||||
},
|
||||
close() {
|
||||
closed = true;
|
||||
pass++;
|
||||
if (timer) stop(timer);
|
||||
timer = null;
|
||||
release();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createPowerGuard, keepsAwake, PREF_KEY, POLL_MS, KIND, IDLE_PASSES, SILENT_PASSES };
|
||||
@@ -1,22 +1,38 @@
|
||||
"use strict";
|
||||
|
||||
// No menu this shell installs carries a reload role, and a page gone blank,
|
||||
// whether its tree unmounted or its renderer died, has no way back but
|
||||
// quitting. The kernel owns every running turn and a loaded page finds it again.
|
||||
// A page gone blank, whether its tree unmounted or its renderer died, has no
|
||||
// way back but quitting. The kernel owns every running turn and a loaded page
|
||||
// finds it again. F5 and Ctrl/Cmd+R belong to the page, so the recovery chord
|
||||
// is the shifted one.
|
||||
const CRASH_RELOADS = 3;
|
||||
const CRASH_WINDOW_MS = 60_000;
|
||||
|
||||
function reloadAction(input, platform = process.platform) {
|
||||
if (input.type !== "keyDown" || input.isAutoRepeat || input.alt) return null;
|
||||
const mod = platform === "darwin" ? input.meta && !input.control : input.control && !input.meta;
|
||||
const latin = typeof input.key === "string" && /^[a-z]$/i.test(input.key);
|
||||
const isR = latin ? input.key.toLowerCase() === "r" : input.code === "KeyR";
|
||||
if (mod && isR) return input.shift ? "hard" : "reload";
|
||||
if (!input.control && !input.meta && !input.shift && input.key === "F5") return "reload";
|
||||
return null;
|
||||
}
|
||||
|
||||
function installPaneReload(contents, platform = process.platform) {
|
||||
contents.on("before-input-event", (event, input) => {
|
||||
const action = reloadAction(input, platform);
|
||||
if (!action) return;
|
||||
event.preventDefault();
|
||||
if (action === "hard") contents.reloadIgnoringCache();
|
||||
else contents.reload();
|
||||
});
|
||||
}
|
||||
|
||||
// A dead renderer's input never reaches before-input-event, so the crash itself
|
||||
// has to trigger the reload, or for a hidden window the next revive before it is
|
||||
// shown. A page that dies on every load stops being reloaded.
|
||||
function installReload(contents, window, { platform = process.platform, now = Date.now } = {}) {
|
||||
const mac = platform === "darwin";
|
||||
contents.on("before-input-event", (event, input) => {
|
||||
if (input.type !== "keyDown" || input.isAutoRepeat || input.alt || input.shift) return;
|
||||
const mod = mac ? input.meta && !input.control : input.control && !input.meta;
|
||||
const bare = !input.control && !input.meta;
|
||||
const chord = mod && (input.key === "r" || input.key === "R" || input.code === "KeyR");
|
||||
if (!chord && !(bare && input.key === "F5")) return;
|
||||
if (reloadAction(input, platform) !== "hard") return;
|
||||
event.preventDefault();
|
||||
contents.reload();
|
||||
});
|
||||
@@ -40,4 +56,4 @@ function installReload(contents, window, { platform = process.platform, now = Da
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { installReload, CRASH_RELOADS, CRASH_WINDOW_MS };
|
||||
module.exports = { installReload, installPaneReload, reloadAction, CRASH_RELOADS, CRASH_WINDOW_MS };
|
||||
+404
-25
@@ -6,17 +6,35 @@ import os from "node:os";
|
||||
import fs from "node:fs";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { parse, readActs } = require("../src/host.js");
|
||||
const { parse, readActs, start } = require("../src/host.js");
|
||||
const { contextTemplate, editMenuTemplate, applicationMenuTemplate, menuInstaller } = require("../src/editmenu.js");
|
||||
const { uiLanguage } = require("../src/uilang.js");
|
||||
const { externalTarget } = require("../src/links.js");
|
||||
const { offerCleanup, ownBundle } = require("../src/legacy.js");
|
||||
const { stripPackageGrants, readReport, unpaintedWindowCause } = require("../src/packagegrants.js");
|
||||
const { groundFor, GROUND } = require("../src/ground.js");
|
||||
const { pick, loadPrefs, savePrefs, registerPrefs } = require("../src/prefs.js");
|
||||
|
||||
const TOKEN = "a".repeat(64);
|
||||
const line = (over) => JSON.stringify({ version: 1, origin: "http://127.0.0.1:8080", token: TOKEN, ...over });
|
||||
|
||||
test("the spawned host receives the system language and inherits explicit locale overrides", async () => {
|
||||
const script = `console.log(${JSON.stringify(line())});
|
||||
console.log(JSON.stringify({act: JSON.stringify({system: process.env.REASONIX_SYSTEM_LANG,
|
||||
explicit: process.env.REASONIX_LANG})}));`;
|
||||
const before = process.env.REASONIX_SYSTEM_LANG;
|
||||
const explicit = process.env.REASONIX_LANG;
|
||||
const state = new Promise((resolve) => {
|
||||
const host = start(process.execPath, ["-e", script], {
|
||||
systemLanguage: "zh-Hant-TW",
|
||||
onAct: (act) => resolve(JSON.parse(act)),
|
||||
});
|
||||
host.ready.catch(resolve);
|
||||
});
|
||||
assert.deepEqual(await state, { system: "zh-Hant-TW", ...(explicit === undefined ? {} : { explicit }) });
|
||||
assert.equal(process.env.REASONIX_SYSTEM_LANG, before);
|
||||
});
|
||||
|
||||
test("the handshake is accepted only when every field accounts for itself", () => {
|
||||
assert.deepEqual(parse(line()), { origin: "http://127.0.0.1:8080", token: TOKEN });
|
||||
|
||||
@@ -665,7 +683,8 @@ test("an unpainted window is attributed only to grants the kernel could not remo
|
||||
});
|
||||
|
||||
const { BrowserProtocol, PAGE_SESSION } = require("../src/browserprotocol.js");
|
||||
const { guestNavigationAllowed, typedAddress } = require("../src/browserguard.js");
|
||||
const { guestNavigationAllowed, typedAddress, typed } = require("../src/browserguard.js");
|
||||
const { localPath } = require("../src/localpath.js");
|
||||
const { sseData } = require("../src/browserrelay.js");
|
||||
|
||||
function fakeBrowser() {
|
||||
@@ -788,6 +807,65 @@ test("an address that names its scheme is loaded as written", () => {
|
||||
assert.equal(guestNavigationAllowed(typedAddress("file:///C:/x").url, "http://127.0.0.1:1"), false);
|
||||
});
|
||||
|
||||
test("a typed path on disk is the file it names, read by the table the kernel is held to", () => {
|
||||
const table = JSON.parse(fs.readFileSync(new URL("../../../internal/platform/browser/testdata/local_paths.json", import.meta.url), "utf8"));
|
||||
for (const { in: raw, out, kind } of table) {
|
||||
const got = localPath(raw);
|
||||
assert.deepEqual(got ? { url: got.url, kind: got.kind } : { url: "", kind: "" }, { url: out, kind }, JSON.stringify(raw));
|
||||
}
|
||||
});
|
||||
|
||||
test("a seeded corpus of hostile addresses reaches the same digest as the kernel's parser, and never accepts a share", () => {
|
||||
const tokens = [
|
||||
"file:", "file:/", "/", "//", "\\", ":", "%", "5C", "2F", "09", "0A", ".", "..", "#", "?",
|
||||
"D", "c", "$", " ", "h", "x", "\t", "\n", "\r", "", "\u0085", "\u0001", "\u007f",
|
||||
"localhost", "C:", "UNC", "%5c", "%2e", "?\\", " ",
|
||||
];
|
||||
let a = 12321;
|
||||
const next = () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return (t ^ (t >>> 14)) >>> 0;
|
||||
};
|
||||
let h = 0x811c9dc5;
|
||||
let accepted = 0;
|
||||
for (let i = 0; i < 30000; i++) {
|
||||
let input = next() % 2 === 0 ? "file:" : "";
|
||||
for (let n = 1 + (next() % 12); n > 0; n--) input += tokens[next() % tokens.length];
|
||||
const got = localPath(input);
|
||||
const out = got ? got.url : "";
|
||||
const kind = got ? got.kind : "";
|
||||
for (const byte of Buffer.from(`${input}\0${out}\0${kind}\n`, "utf8")) h = Math.imul(h ^ byte, 0x01000193) >>> 0;
|
||||
if (kind !== "local") continue;
|
||||
accepted++;
|
||||
const u = new URL(out);
|
||||
assert.ok(u.protocol === "file:" && u.hostname === "" && !u.pathname.startsWith("//"), `${JSON.stringify(input)} accepted as ${out}`);
|
||||
assert.ok(!/[\u0000-\u001f\u007f]/.test(out), `${JSON.stringify(input)} kept a control character`);
|
||||
}
|
||||
assert.ok(accepted > 1000, `only ${accepted} accepted`);
|
||||
assert.equal(h.toString(16).padStart(8, "0"), "67b363c1");
|
||||
});
|
||||
|
||||
test("a path typed in the address bar loads as a file, not as a host named by its drive letter", () => {
|
||||
const kernel = "http://127.0.0.1:4455";
|
||||
const refusal = (raw) => typed(raw, kernel).refusal;
|
||||
const reporter = typed("D:/DevCode/MyProjects/aglo/aglo.html", kernel);
|
||||
assert.deepEqual(reporter, { url: "file:///D:/DevCode/MyProjects/aglo/aglo.html", fallback: "", refusal: "" });
|
||||
for (const ok of ["/Users/me/页面.html", "file:///D:/x.html", "file:/tmp/x.html", "file://localhost/tmp/x.html", "localhost:3000/x"]) {
|
||||
assert.equal(refusal(ok), "", ok);
|
||||
}
|
||||
for (const share of ["\\\\nas\\share\\x.html", "file://nas/share/x.html", "file:////host/x", "file://localhost//host/x", "file://///host/share", "file:///\\\\host\\x", "file:///%5C%5Chost/x", "file:///%2F%2Fhost/x", "\\\\?\\UNC\\host\\x"]) {
|
||||
assert.deepEqual(typed(share, kernel), { url: "", fallback: "", refusal: "network_file" }, share);
|
||||
}
|
||||
assert.equal(refusal("javascript://x%0Aalert(1)"), "scheme");
|
||||
assert.equal(refusal("http://127.0.0.1:4455/_studio/"), "scheme");
|
||||
assert.equal(refusal(""), "scheme");
|
||||
assert.deepEqual(typedAddress("localhost:3000/x"), { url: "http://localhost:3000/x", fallback: "" });
|
||||
assert.deepEqual(typedAddress("a.b/c"), { url: "https://a.b/c", fallback: "http://a.b/c" });
|
||||
});
|
||||
|
||||
test("the relay reads whole SSE data frames and keeps what is unfinished", () => {
|
||||
const first = sseData(': connected\n\ndata: {"conn":"1"}\n\ndata: {"co');
|
||||
assert.deepEqual(first.data, ['{"conn":"1"}']);
|
||||
@@ -1001,30 +1079,67 @@ function reloadRig(platform = "darwin") {
|
||||
return { state, send, gone, revive };
|
||||
}
|
||||
|
||||
test("the reload key brings back a window whose page stopped drawing", () => {
|
||||
const press = (over) => ({ type: "keyDown", key: "r", code: "KeyR", control: false, alt: false, shift: false, meta: false, isAutoRepeat: false, ...over });
|
||||
const cases = [
|
||||
["darwin", { meta: true }, { control: true }],
|
||||
["win32", { control: true }, { meta: true }],
|
||||
["linux", { control: true }, { meta: true }],
|
||||
];
|
||||
for (const [platform, mod, other] of cases) {
|
||||
const reloadPress = (over) => ({ type: "keyDown", key: "r", code: "KeyR", control: false, alt: false, shift: false, meta: false, isAutoRepeat: false, ...over });
|
||||
const reloadMods = [
|
||||
["darwin", { meta: true }, { control: true }],
|
||||
["win32", { control: true }, { meta: true }],
|
||||
["linux", { control: true }, { meta: true }],
|
||||
];
|
||||
|
||||
test("reloadAction reads the chord, not the layout", () => {
|
||||
const { reloadAction } = require("../src/reload.js");
|
||||
for (const [platform, mod, other] of reloadMods) {
|
||||
const at = (input) => reloadAction(input, platform);
|
||||
assert.equal(at(reloadPress(mod)), "reload");
|
||||
assert.equal(at(reloadPress({ ...mod, key: "к" })), "reload");
|
||||
assert.equal(at(reloadPress({ key: "F5", code: "F5" })), "reload");
|
||||
assert.equal(at(reloadPress({ ...mod, key: "p", code: "KeyR" })), null, `${platform}: a Dvorak P reloaded`);
|
||||
assert.equal(at(reloadPress({ ...mod, key: "r", code: "KeyP" })), "reload", `${platform}: a Dvorak R did not reload`);
|
||||
assert.equal(at(reloadPress({ ...mod, shift: true })), "hard");
|
||||
assert.equal(at(reloadPress({ ...mod, shift: true, key: "R" })), "hard");
|
||||
for (const input of [
|
||||
reloadPress(), reloadPress(other), reloadPress({ ...mod, alt: true }),
|
||||
reloadPress({ ...mod, type: "keyUp" }), reloadPress({ ...mod, isAutoRepeat: true }),
|
||||
reloadPress({ ...mod, key: "t", code: "KeyT" }), reloadPress({ key: "F5", code: "F5", control: true }),
|
||||
reloadPress({ key: "F5", code: "F5", shift: true }), reloadPress({ shift: true }),
|
||||
]) assert.equal(at(input), null, `${platform}: ${JSON.stringify(input)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("only the hard reload chord reloads the app window; F5 and Ctrl/Cmd+R reach the page", () => {
|
||||
for (const [platform, mod] of reloadMods) {
|
||||
const { state, send } = reloadRig(platform);
|
||||
send(press(mod));
|
||||
send(press({ ...mod, key: "R" }));
|
||||
send(press({ ...mod, key: "к" }));
|
||||
send(press({ key: "F5", code: "F5" }));
|
||||
assert.equal(state.reloads, 4, `${platform}: the reload keys did not reload`);
|
||||
send(press());
|
||||
send(press(other));
|
||||
send(press({ ...mod, shift: true }));
|
||||
send(press({ ...mod, alt: true }));
|
||||
send(press({ ...mod, type: "keyUp" }));
|
||||
send(press({ ...mod, isAutoRepeat: true }));
|
||||
send(press({ ...mod, key: "t", code: "KeyT" }));
|
||||
send(press({ key: "F5", code: "F5", control: true }));
|
||||
assert.equal(state.reloads, 4, `${platform}: something other than a fresh reload press reloaded`);
|
||||
assert.equal(state.prevented, 4);
|
||||
send(reloadPress(mod));
|
||||
send(reloadPress({ key: "F5", code: "F5" }));
|
||||
assert.equal(state.reloads, 0, `${platform}: a plain reload key reloaded the UI`);
|
||||
assert.equal(state.prevented, 0, `${platform}: a plain reload key was swallowed`);
|
||||
send(reloadPress({ ...mod, shift: true, key: "R" }));
|
||||
assert.equal(state.reloads, 1, `${platform}: the recovery chord did not reload`);
|
||||
assert.equal(state.prevented, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test("a browser pane reloads on F5 and Ctrl/Cmd+R, ignoring cache on the shifted chord, and swallows nothing else", () => {
|
||||
const { installPaneReload } = require("../src/reload.js");
|
||||
for (const [platform, mod] of reloadMods) {
|
||||
let handler;
|
||||
const calls = { reload: 0, hard: 0, prevented: 0 };
|
||||
const contents = {
|
||||
on: (name, fn) => { if (name === "before-input-event") handler = fn; },
|
||||
reload: () => { calls.reload += 1; },
|
||||
reloadIgnoringCache: () => { calls.hard += 1; },
|
||||
};
|
||||
installPaneReload(contents, platform);
|
||||
const send = (input) => handler({ preventDefault: () => { calls.prevented += 1; } }, input);
|
||||
send(reloadPress(mod));
|
||||
send(reloadPress({ key: "F5", code: "F5" }));
|
||||
assert.deepEqual(calls, { reload: 2, hard: 0, prevented: 2 }, platform);
|
||||
send(reloadPress({ ...mod, shift: true, key: "R" }));
|
||||
assert.deepEqual(calls, { reload: 2, hard: 1, prevented: 3 }, platform);
|
||||
send(reloadPress({ ...mod, key: "f", code: "KeyF" }));
|
||||
send(reloadPress({ key: "F12", code: "F12" }));
|
||||
send(reloadPress({ ...mod, alt: true }));
|
||||
assert.deepEqual(calls, { reload: 2, hard: 1, prevented: 3 }, `${platform}: another key was handled`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1342,3 +1457,267 @@ test("the tray has a file for every Windows scale, at exactly 16 * scale pixels"
|
||||
assert.equal(trayAsset(5).pixels, 48);
|
||||
assert.equal(trayAsset(NaN).pixels, 16);
|
||||
});
|
||||
|
||||
function pe(machine) {
|
||||
const b = Buffer.alloc(0x100);
|
||||
b.writeUInt16LE(0x5a4d, 0);
|
||||
b.writeUInt32LE(0x80, 0x3c);
|
||||
b.writeUInt32LE(0x00004550, 0x80);
|
||||
b.writeUInt16LE(machine, 0x84);
|
||||
return b;
|
||||
}
|
||||
|
||||
test("the PE check reads each image's own machine and refuses a tree of another architecture", () => {
|
||||
const { peMachine, machines, foreign } = require("../packaging/pe.js");
|
||||
assert.equal(peMachine(pe(0xaa64)), 0xaa64);
|
||||
assert.throws(() => peMachine(Buffer.from("#!/bin/sh\n".repeat(10))), /not a PE/);
|
||||
assert.throws(() => peMachine(pe(0x8664).subarray(0, 0x70)), /no PE signature/);
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "reasonix-pe-"));
|
||||
try {
|
||||
assert.throws(() => foreign(dir, "arm64"), /no PE images/);
|
||||
fs.mkdirSync(path.join(dir, "resources", "bin"), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, "app.exe"), pe(0xaa64));
|
||||
fs.writeFileSync(path.join(dir, "resources", "bin", "host.exe"), pe(0xaa64));
|
||||
fs.writeFileSync(path.join(dir, "locales.pak"), "not an image");
|
||||
fs.writeFileSync(path.join(dir, "noext"), pe(0xaa64));
|
||||
assert.equal(machines(dir).length, 3);
|
||||
assert.deepEqual(foreign(dir, "arm64"), []);
|
||||
assert.deepEqual(foreign(dir, "amd64").map((f) => f.file), ["app.exe", "noext", "resources/bin/host.exe"]);
|
||||
fs.writeFileSync(path.join(dir, "ffmpeg.dll"), pe(0x8664));
|
||||
assert.deepEqual(foreign(dir, "arm64").map((f) => f.file), ["ffmpeg.dll"]);
|
||||
assert.throws(() => foreign(dir, "x64"), /unknown architecture/);
|
||||
fs.writeFileSync(path.join(dir, "broken.dll"), "text, not an image");
|
||||
const broken = foreign(dir, "arm64").find((f) => f.file === "broken.dll");
|
||||
assert.match(broken.error, /not a PE/);
|
||||
fs.rmSync(path.join(dir, "broken.dll"));
|
||||
fs.writeFileSync(path.join(dir, "ffmpeg.dll"), pe(0xaa64));
|
||||
fs.writeFileSync(path.join(dir, "resources", "elevate.exe"), pe(0x14c));
|
||||
assert.deepEqual(foreign(dir, "arm64"), []);
|
||||
fs.writeFileSync(path.join(dir, "Uninstall Reasonix Studio.exe"), pe(0x14c));
|
||||
assert.deepEqual(foreign(dir, "arm64"), []);
|
||||
fs.writeFileSync(path.join(dir, "resources", "elevate.exe"), pe(0xaa64));
|
||||
assert.deepEqual(foreign(dir, "arm64").map((f) => f.file), ["resources/elevate.exe"]);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const { createPowerGuard, keepsAwake, IDLE_PASSES, SILENT_PASSES } = require("../src/powerguard.js");
|
||||
|
||||
function fakeBlocker() {
|
||||
const live = new Set();
|
||||
let next = 1;
|
||||
const calls = { start: 0, stop: 0 };
|
||||
return {
|
||||
calls,
|
||||
live,
|
||||
start(kind) {
|
||||
assert.equal(kind, "prevent-app-suspension");
|
||||
calls.start++;
|
||||
live.add(next);
|
||||
return next++;
|
||||
},
|
||||
stop(id) {
|
||||
calls.stop++;
|
||||
live.delete(id);
|
||||
},
|
||||
isStarted: (id) => live.has(id),
|
||||
};
|
||||
}
|
||||
|
||||
// answer is what the kernel says next: { panes, unknown }, null for a kernel
|
||||
// that cannot answer, or an Error to throw.
|
||||
function guardOver(state) {
|
||||
const blocker = fakeBlocker();
|
||||
const guard = createPowerGuard({
|
||||
blocker,
|
||||
running: async () => {
|
||||
if (state.answer instanceof Error) throw state.answer;
|
||||
return state.answer;
|
||||
},
|
||||
enabled: () => state.enabled !== false,
|
||||
});
|
||||
return { blocker, guard };
|
||||
}
|
||||
const busy = (panes) => ({ panes, unknown: 0 });
|
||||
const idle = { panes: 0, unknown: 0 };
|
||||
async function times(n, guard) {
|
||||
for (let i = 0; i < n; i++) await guard.refresh();
|
||||
}
|
||||
|
||||
test("a running turn holds the computer awake at once, once, however many panes run", async () => {
|
||||
const state = { answer: busy(1) };
|
||||
const { blocker, guard } = guardOver(state);
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.live.size, 1);
|
||||
await guard.refresh();
|
||||
state.answer = busy(4);
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.calls.start, 1);
|
||||
});
|
||||
|
||||
test("the hold is let go only after the third idle answer in a row", async () => {
|
||||
const state = { answer: busy(2) };
|
||||
const { blocker, guard } = guardOver(state);
|
||||
await guard.refresh();
|
||||
state.answer = idle;
|
||||
await times(IDLE_PASSES - 1, guard);
|
||||
assert.equal(blocker.live.size, 1, "two idle answers are not enough");
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.live.size, 0);
|
||||
assert.equal(blocker.calls.stop, 1);
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.calls.stop, 1);
|
||||
});
|
||||
|
||||
test("a turn showing up between idle answers starts the count again", async () => {
|
||||
const state = { answer: busy(1) };
|
||||
const { blocker, guard } = guardOver(state);
|
||||
await guard.refresh();
|
||||
state.answer = idle;
|
||||
await times(IDLE_PASSES - 1, guard);
|
||||
state.answer = busy(1);
|
||||
await guard.refresh();
|
||||
state.answer = idle;
|
||||
await times(IDLE_PASSES - 1, guard);
|
||||
assert.equal(blocker.live.size, 1);
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.live.size, 0);
|
||||
});
|
||||
|
||||
test("an idle kernel never takes the hold", async () => {
|
||||
const { blocker, guard } = guardOver({ answer: idle });
|
||||
await times(5, guard);
|
||||
assert.equal(blocker.calls.start, 0);
|
||||
});
|
||||
|
||||
test("a kernel that cannot answer, or a pane that cannot be asked, changes nothing", async () => {
|
||||
for (const unsure of [null, new Error("kernel gone"), { panes: 0, unknown: 1 }, {}]) {
|
||||
const state = { answer: busy(1) };
|
||||
const { blocker, guard } = guardOver(state);
|
||||
await guard.refresh();
|
||||
state.answer = idle;
|
||||
await times(IDLE_PASSES - 1, guard);
|
||||
state.answer = unsure;
|
||||
await times(SILENT_PASSES - 1, guard);
|
||||
assert.equal(blocker.live.size, 1, `held through ${String(unsure)}`);
|
||||
assert.equal(blocker.calls.stop, 0, "silence did not count as the idle answer that was missing");
|
||||
}
|
||||
});
|
||||
|
||||
test("silence does not take the hold either", async () => {
|
||||
const { blocker, guard } = guardOver({ answer: null });
|
||||
await times(3, guard);
|
||||
assert.equal(blocker.calls.start, 0);
|
||||
});
|
||||
|
||||
test("a hold nobody can confirm is let go after SILENT_PASSES, so it cannot leak", async () => {
|
||||
const state = { answer: busy(1) };
|
||||
const { blocker, guard } = guardOver(state);
|
||||
await guard.refresh();
|
||||
state.answer = null;
|
||||
await times(SILENT_PASSES, guard);
|
||||
assert.equal(blocker.live.size, 0);
|
||||
});
|
||||
|
||||
test("turning the setting off lets go at once and keeps it off while turns run", async () => {
|
||||
const state = { answer: busy(1) };
|
||||
const { blocker, guard } = guardOver(state);
|
||||
await guard.refresh();
|
||||
state.enabled = false;
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.live.size, 0);
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.calls.start, 1);
|
||||
state.enabled = true;
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.live.size, 1);
|
||||
});
|
||||
|
||||
test("a block the system dropped is taken again while work continues", async () => {
|
||||
const { blocker, guard } = guardOver({ answer: busy(1) });
|
||||
await guard.refresh();
|
||||
blocker.live.clear();
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.live.size, 1);
|
||||
assert.equal(blocker.calls.start, 2);
|
||||
});
|
||||
|
||||
test("an answer that arrives after a newer one does not overturn it", async () => {
|
||||
const blocker = fakeBlocker();
|
||||
const pending = [];
|
||||
const guard = createPowerGuard({
|
||||
blocker,
|
||||
running: () => new Promise((resolve) => pending.push(resolve)),
|
||||
enabled: () => true,
|
||||
});
|
||||
const slow = guard.refresh();
|
||||
const fresh = guard.refresh();
|
||||
pending[1](busy(2));
|
||||
await fresh;
|
||||
pending[0](idle);
|
||||
await slow;
|
||||
assert.equal(blocker.live.size, 1);
|
||||
});
|
||||
|
||||
test("a refresh asked for the moment a turn is submitted takes the hold without waiting for the poll", async () => {
|
||||
const state = { answer: idle };
|
||||
const { blocker, guard } = guardOver(state);
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.live.size, 0);
|
||||
state.answer = busy(1);
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.live.size, 1);
|
||||
});
|
||||
|
||||
test("closing the guard releases the hold and stops the poll", async () => {
|
||||
const timers = [];
|
||||
const blocker = fakeBlocker();
|
||||
const guard = createPowerGuard({
|
||||
blocker,
|
||||
running: async () => busy(1),
|
||||
enabled: () => true,
|
||||
setInterval: (fn, ms) => {
|
||||
timers.push({ fn, ms, cleared: false });
|
||||
return timers.at(-1);
|
||||
},
|
||||
clearInterval: (t) => (t.cleared = true),
|
||||
});
|
||||
guard.begin();
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.live.size, 1);
|
||||
guard.close();
|
||||
assert.equal(blocker.live.size, 0);
|
||||
assert.equal(timers.length, 1);
|
||||
assert.equal(timers[0].cleared, true);
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.live.size, 0, "a closed guard takes nothing back");
|
||||
});
|
||||
|
||||
test("keeping awake is on unless this machine said off", () => {
|
||||
assert.equal(keepsAwake({}), true);
|
||||
assert.equal(keepsAwake(undefined), true);
|
||||
assert.equal(keepsAwake({ "rx-keep-awake": "on" }), true);
|
||||
assert.equal(keepsAwake({ "rx-keep-awake": "off" }), false);
|
||||
});
|
||||
|
||||
test("the host client asks the kernel how many panes run", async () => {
|
||||
const server = http.createServer((req, res) => {
|
||||
res.writeHead(req.url === "/tray/running" ? 200 : 404, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ panes: 3 }));
|
||||
});
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
const client = new StudioHost(`http://127.0.0.1:${server.address().port}`, "t");
|
||||
assert.deepEqual(await client.trayRunning(), { panes: 3 });
|
||||
server.close();
|
||||
assert.equal(await client.trayRunning(), null);
|
||||
});
|
||||
|
||||
test("the window ground follows the stored theme and, for auto, the system", () => {
|
||||
assert.equal(groundFor({ "rx-theme": "dark" }, false), GROUND.dark);
|
||||
assert.equal(groundFor({ "rx-theme": "light" }, true), GROUND.light);
|
||||
assert.equal(groundFor({ "rx-theme": "auto" }, true), GROUND.dark);
|
||||
assert.equal(groundFor({}, false), GROUND.light);
|
||||
assert.equal(groundFor(undefined, true), GROUND.dark);
|
||||
});
|
||||
@@ -41,7 +41,9 @@
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(pair ? { code: pair } : { token: token }),
|
||||
}).then(function () {});
|
||||
}).then(function (response) {
|
||||
if (response.ok) window.location.reload();
|
||||
});
|
||||
} catch (e) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -29,9 +29,9 @@
|
||||
"@codemirror/search": "^6.7.2",
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.13",
|
||||
"@lezer/highlight": "^1.2.4",
|
||||
"@lezer/highlight": "^1.2.5",
|
||||
"codemirror": "^6.0.2",
|
||||
"katex": "^0.18.9",
|
||||
"katex": "^0.18.10",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -53,7 +53,7 @@
|
||||
"jsdom": "^30.1.1",
|
||||
"playwright": "^1.63.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.0",
|
||||
"vitest": "^5.0.1"
|
||||
"vite": "^8.3.2",
|
||||
"vitest": "^5.0.3"
|
||||
}
|
||||
}
|
||||
@@ -54,13 +54,18 @@ node perf/i18n.mjs # 词表守卫:源码用到的中文 key 与英文词
|
||||
node perf/selfcheck.mjs # 守卫的守卫:往每个断言脚本塞一条必假断言,看它是否真的会红
|
||||
node perf/codes.mjs # 码的守卫:内核发的每个拒绝码,前端都要有话说
|
||||
node perf/tokens.mjs # 变量的守卫:样式里不带兜底的 var(--x),都得真有人赋过值
|
||||
node perf/tiers.mjs # 墨色分档的守卫:每档的正文/次要/淡/幽灵色不低于首次绘制的那一步,且都过 AA
|
||||
node perf/contrast.mjs # 对比度的守卫:每一段正文对它真正落在的那块底够不够 AA
|
||||
node perf/concurrent.mjs # 多会话并发的守卫:9 个会话同时流式,后台窗格不许以流式速率重绘,帧率不许掉(手跑,不进自动检查:帧率比值阈值在 2 核机器上随硬件抖动,CI 上实测 0.68/0.69 < 0.7)
|
||||
node perf/wheel.mjs # 滚轮轨迹(只报告、不判红):向上滚时 scrollTop 不倒走、单步不超滚轮、scrollHeight 不缩水;WHEEL_STRICT=1 判红
|
||||
node perf/idle.mjs # 闲置的守卫:看不见的东西不许动,闲着的窗口不许烧 CPU
|
||||
node perf/effects.mjs # Effects guard: a sky pack idles quietly, a hidden window draws no sky, Reduced removes blur, sky and grain
|
||||
node perf/shipped.mjs # 产物的守卫:dist 里的东西必须比源码新(构建可以成功而编的是别的树)
|
||||
node perf/reason.mjs # 内核拒绝的双语落地:同一个码,中英各说各的
|
||||
node perf/budget.mjs # 预算的守卫:输入框上下堆满东西时,转录还剩多少
|
||||
node perf/find.mjs # 对话内查找:快捷键穿过输入框、跳到没挂载的那条、同一行里的第 n 处落在眼前
|
||||
node perf/focus.mjs # 专注的守卫:拿走外围之后,退出时世界是否原样
|
||||
node perf/tooltip.mjs # 控件提示的守卫:文字不溢出提示框、不在半透明祖先之下、对比度达 AA(深浅×中英×宽窄)
|
||||
```
|
||||
|
||||
`lang.mjs` 从仓库外跑时用 `PERF_SRC` 指向 `src/`(它要读固件源码来决定
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// Many sessions streaming at once (#12232): hidden panes must not be rewritten at
|
||||
// stream rate, and N=PERF_GUARD must keep FPS_RATIO_MIN of the N=1 frame rate.
|
||||
// PERF_N=1,3,6,9 lists more rows; the verdict compares only N=1 with PERF_GUARD.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const URL = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const CPU = Number(process.env.PERF_CPU ?? 4);
|
||||
const SECS = Number(process.env.PERF_SECS ?? 5);
|
||||
const TURNS = Number(process.env.PERF_TURNS ?? 40);
|
||||
const GUARD = Number(process.env.PERF_GUARD ?? 9);
|
||||
const COUNTS = (process.env.PERF_N ?? `1,${GUARD}`).split(",").map(Number);
|
||||
const WRITES_MAX = Number(process.env.PERF_WRITES_MAX ?? 4);
|
||||
const FPS_RATIO_MIN = Number(process.env.PERF_FPS_RATIO ?? 0.7);
|
||||
|
||||
const THOUGHT_CHARS = Number(process.env.PERF_THOUGHT ?? 3000);
|
||||
const THOUGHT = "先看一下这个文件的结构,再决定改哪里;要注意调用方、回放路径与错误状态。".repeat(Math.ceil(THOUGHT_CHARS / 34 / 40));
|
||||
const browser = await chromium.launch();
|
||||
const rows = [];
|
||||
const fails = [];
|
||||
|
||||
for (const n of COUNTS) {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
const cdp = await ctx.newCDPSession(page);
|
||||
if (CPU > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU });
|
||||
await cdp.send("Performance.enable");
|
||||
await page.goto(`${URL}?panes=${n}`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app", { timeout: 15000 });
|
||||
await page.waitForTimeout(700);
|
||||
|
||||
await page.evaluate(async (k) => {
|
||||
const yieldFrame = () => new Promise((r) => requestAnimationFrame(r));
|
||||
for (let i = 0; i < k; i++) {
|
||||
window.__feed({ kind: "turn_started" });
|
||||
window.__feed({ kind: "reasoning", text: "先看文件再决定改哪里。" });
|
||||
window.__feed({ kind: "text", text: `第 ${i} 段回答。\n\n- 要点一\n- 要点二\n\n` });
|
||||
window.__feed({ kind: "message" });
|
||||
window.__feed({ kind: "turn_done" });
|
||||
if (i % 10 === 9) await yieldFrame();
|
||||
}
|
||||
await yieldFrame();
|
||||
}, TURNS);
|
||||
await page.waitForTimeout(500);
|
||||
await page.evaluate((t) => {
|
||||
window.__feed({ kind: "turn_started" });
|
||||
for (let i = 0; i < 40; i++) window.__feed({ kind: "reasoning", text: t });
|
||||
}, THOUGHT);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const read = async () => Object.fromEntries((await cdp.send("Performance.getMetrics")).metrics.map((m) => [m.name, m.value]));
|
||||
async function phase(kind) {
|
||||
const before = await read();
|
||||
const r = await page.evaluate(
|
||||
([k, secs]) =>
|
||||
new Promise((resolve) => {
|
||||
let frames = 0;
|
||||
let worst = 0;
|
||||
const t0 = performance.now();
|
||||
let last = t0;
|
||||
const tick = () => {
|
||||
frames++;
|
||||
const now = performance.now();
|
||||
worst = Math.max(worst, now - last);
|
||||
last = now;
|
||||
if (now - t0 < secs * 1000) requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
let writes = 0;
|
||||
const mo = new MutationObserver((ms) => {
|
||||
for (const m of ms) {
|
||||
const at = m.target instanceof Element ? m.target : m.target.parentElement;
|
||||
if (at?.closest(".pane[data-off] .out")) writes++;
|
||||
}
|
||||
});
|
||||
mo.observe(document.body, { subtree: true, characterData: true, childList: true });
|
||||
const feed = setInterval(() => window.__feed({ kind: k, text: "继续往下想一小段文字," }), 33);
|
||||
setTimeout(() => {
|
||||
clearInterval(feed);
|
||||
mo.disconnect();
|
||||
resolve({ wall: performance.now() - t0, frames, worst, writes });
|
||||
}, secs * 1000);
|
||||
}),
|
||||
[kind, SECS],
|
||||
);
|
||||
const after = await read();
|
||||
const d = (key) => (after[key] ?? 0) - (before[key] ?? 0);
|
||||
return {
|
||||
N: n,
|
||||
阶段: kind === "reasoning" ? "thinking" : "answering",
|
||||
"主线程%": ((d("TaskDuration") * 1000 * 100) / r.wall).toFixed(0),
|
||||
"脚本%": ((d("ScriptDuration") * 1000 * 100) / r.wall).toFixed(0),
|
||||
"样式重算/s": (d("RecalcStyleCount") / (r.wall / 1000)).toFixed(0),
|
||||
"布局/s": (d("LayoutCount") / (r.wall / 1000)).toFixed(0),
|
||||
fps: ((r.frames / r.wall) * 1000).toFixed(1),
|
||||
"最长帧/ms": r.worst.toFixed(0),
|
||||
"后台写/s/窗格": n > 1 ? (r.writes / (r.wall / 1000) / (n - 1)).toFixed(1) : "-",
|
||||
节点: after.Nodes,
|
||||
};
|
||||
}
|
||||
const a = await phase("reasoning");
|
||||
await page.evaluate(() => window.__feed({ kind: "text", text: "开始回答。" }));
|
||||
const b = await phase("text");
|
||||
rows.push(a, b);
|
||||
await ctx.close();
|
||||
}
|
||||
await browser.close();
|
||||
console.log(`降频 ${CPU}x,每个窗格 ${TURNS} 轮历史,每阶段 ${SECS}s\n`);
|
||||
console.table(rows);
|
||||
for (const phase of ["thinking", "answering"]) {
|
||||
const one = rows.find((r) => r.N === 1 && r.阶段 === phase);
|
||||
const many = rows.find((r) => r.N === GUARD && r.阶段 === phase);
|
||||
if (!one || !many) {
|
||||
fails.push(`${phase} 缺 N=1 或 N=${GUARD} 的读数`);
|
||||
continue;
|
||||
}
|
||||
const writes = Number(many["后台写/s/窗格"]);
|
||||
const ratio = Number(many.fps) / Number(one.fps);
|
||||
console.log(`${phase}: 后台窗格每秒写 ${writes} 次(≤ ${WRITES_MAX}),N=${GUARD} 帧率 / N=1 帧率 = ${ratio.toFixed(2)}(≥ ${FPS_RATIO_MIN})`);
|
||||
if (writes > WRITES_MAX) fails.push(`${phase} 后台窗格每秒写 ${writes} 次 > ${WRITES_MAX}`);
|
||||
if (ratio < FPS_RATIO_MIN) fails.push(`${phase} 帧率比 ${ratio.toFixed(2)} < ${FPS_RATIO_MIN}`);
|
||||
}
|
||||
console.log(fails.length ? `\n失败 ${fails.length} 项:\n- ${fails.join("\n- ")}` : "\n全部通过");
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
@@ -0,0 +1,139 @@
|
||||
// Effects guard: decoration must not keep the GPU busy. A window with a sky
|
||||
// pack drew a blurred cloud bank under six frosted panels, so every frame
|
||||
// re-blurred 41% of the screen; the sky never stopped for a window nobody was
|
||||
// looking at. Idle has to be idle, and "Reduced" has to remove those layers.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
|
||||
async function open(pack, { effects = "", reducedMotion = "no-preference" } = {}) {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, reducedMotion, colorScheme: "dark" });
|
||||
await ctx.addInitScript((v) => {
|
||||
if (v) localStorage.setItem("rx-effects", v);
|
||||
window.__sky = 0;
|
||||
const clear = CanvasRenderingContext2D.prototype.clearRect;
|
||||
CanvasRenderingContext2D.prototype.clearRect = function (...a) {
|
||||
if (this.canvas.width === 400 && this.canvas.height === 225) window.__sky++;
|
||||
return clear.apply(this, a);
|
||||
};
|
||||
}, effects);
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(`${BASE.replace(/\?.*/, "")}?pack=${pack}`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app", { timeout: 15000 });
|
||||
await page.waitForTimeout(1200);
|
||||
return { ctx, page };
|
||||
}
|
||||
|
||||
const skyFrames = async (page, ms) => {
|
||||
const a = await page.evaluate(() => window.__sky);
|
||||
await page.waitForTimeout(ms);
|
||||
return (await page.evaluate(() => window.__sky)) - a;
|
||||
};
|
||||
|
||||
// Surfaces that blur what is behind them, pseudo-elements included: the
|
||||
// transcript's backing layer is one, and it is the largest.
|
||||
const blurred = (page) =>
|
||||
page.evaluate(() => {
|
||||
const out = [];
|
||||
for (const el of document.querySelectorAll("*")) {
|
||||
for (const pseudo of [null, "::before", "::after"]) {
|
||||
const cs = getComputedStyle(el, pseudo);
|
||||
const b = cs.backdropFilter || cs.webkitBackdropFilter;
|
||||
if (!b || b === "none") continue;
|
||||
if (pseudo && cs.content === "none") continue;
|
||||
if (!pseudo && (cs.display === "none" || cs.visibility === "hidden")) continue;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width * r.height === 0) continue;
|
||||
out.push(`${el.tagName.toLowerCase()}.${String(el.className).slice(0, 18)}${pseudo ?? ""}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const timeLoops = (page) =>
|
||||
page.evaluate(() =>
|
||||
document.getAnimations({ subtree: true })
|
||||
.filter((a) => a.playState === "running" && a.effect?.getTiming().iterations === Infinity && a.timeline?.constructor.name === "DocumentTimeline")
|
||||
.map((a) => `${a.animationName ?? "?"} @ ${a.effect.target?.className?.toString?.().slice(0, 20)}`),
|
||||
);
|
||||
|
||||
{
|
||||
const { ctx, page } = await open("sky");
|
||||
check("sky pack: the sky is mounted", (await page.locator(".sky").count()) === 1);
|
||||
const loops = await timeLoops(page);
|
||||
check("sky pack at idle: no CSS loop runs behind the window", loops.length === 0, loops.join("; ") || "0");
|
||||
const n = await skyFrames(page, 3000);
|
||||
check("sky pack at idle: the canvas redraws at most 20 times a second", n <= 60 && n > 0, `${n} redraws in 3 s`);
|
||||
const bd = await blurred(page);
|
||||
check("sky pack: at most two surfaces blur their backdrop", bd.length <= 2, bd.join(", "));
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, "hidden", { configurable: true, get: () => true });
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
check("a hidden window draws no sky", (await skyFrames(page, 1500)) <= 1);
|
||||
await page.evaluate(() => {
|
||||
delete document.hidden;
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
await page.evaluate(() => {
|
||||
document.hasFocus = () => false;
|
||||
window.dispatchEvent(new Event("blur"));
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
check("a window without the keyboard draws no sky", (await skyFrames(page, 1500)) <= 1);
|
||||
await page.evaluate(() => {
|
||||
document.hasFocus = () => true;
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
check("it moves again once shown", (await skyFrames(page, 1500)) > 0);
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
{
|
||||
const { ctx, page } = await open("sky", { reducedMotion: "reduce" });
|
||||
check("prefers-reduced-motion: the sky holds still", (await skyFrames(page, 2000)) === 0);
|
||||
check("prefers-reduced-motion: no CSS loop at idle", (await timeLoops(page)).length === 0);
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
{
|
||||
const { ctx, page } = await open("photo", { effects: "reduced" });
|
||||
const bd = await blurred(page);
|
||||
check("Reduced: nothing blurs its backdrop", bd.length === 0, bd.join(", "));
|
||||
const grain = await page.evaluate(() => getComputedStyle(document.querySelector(".app"), "::after").display);
|
||||
check("Reduced: the grain layer is gone", grain === "none", grain);
|
||||
check("Reduced: data-effects is on the root", (await page.evaluate(() => document.documentElement.dataset.effects)) === "reduced");
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
{
|
||||
const { ctx, page } = await open("sky");
|
||||
await page.keyboard.press("Meta+Comma");
|
||||
await page.waitForTimeout(500);
|
||||
await page.evaluate(() => document.getElementById("prefs-appearance")?.click());
|
||||
await page.waitForSelector('[data-action="appearance.effects"]', { state: "attached", timeout: 5000 });
|
||||
const sky0 = await page.locator(".sky").count();
|
||||
await page.evaluate(() => document.querySelector('[data-action="appearance.effects"][data-value="reduced"]')?.click());
|
||||
await page.waitForTimeout(500);
|
||||
check("choosing Reduced takes the sky away at once", sky0 === 1 && (await page.locator(".sky").count()) === 0);
|
||||
check("choosing Reduced is remembered", (await page.evaluate(() => localStorage.getItem("rx-effects"))) === "reduced");
|
||||
check("Reduced leaves no blurred surface", (await blurred(page)).length === 0);
|
||||
await page.evaluate(() => document.querySelector('[data-action="appearance.effects"][data-value="full"]')?.click());
|
||||
await page.waitForTimeout(500);
|
||||
check("choosing Full brings the sky back", (await page.locator(".sky").count()) === 1);
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(fails.length ? `\n${fails.length} failed:\n- ${fails.join("\n- ")}` : "\nall passed");
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
@@ -10,6 +10,7 @@ import { MockHub } from "../src/port/mock_hub";
|
||||
import { MockPort } from "../src/port/mock";
|
||||
import { fromHistory } from "../src/state/session";
|
||||
import type { AgentPort, Appearance, HistoryMessage } from "../src/port/port";
|
||||
import type { ThemePack } from "../src/port/look";
|
||||
import type { RuntimeView, TreeWorkspace } from "../src/port/hub";
|
||||
import type { WireEvent } from "../src/port/wire";
|
||||
|
||||
@@ -23,9 +24,38 @@ let peakInFlight = 0;
|
||||
|
||||
// A port whose event stream the driver owns outright: the fixture's scripted
|
||||
// beats never reach the UI, so a measurement times exactly the frames it fed.
|
||||
// ?pack=sky opens on a pack with the live sky; ?pack=photo on one with a
|
||||
// picture. Both are the states where the shell goes translucent over a backdrop.
|
||||
const PACK = new URLSearchParams(location.search).get("pack");
|
||||
|
||||
function benchPack(): ThemePack | null {
|
||||
const tokens = {
|
||||
light: { bg: "#F2F6F8", bgSoft: "#F9FCFD", panel: "#FFFFFF", border: "#CBD9E0", fg: "#12191C", fgDim: "#4E5D65", accent: "#0E6E82" },
|
||||
dark: { bg: "#080D10", bgSoft: "#0C1316", panel: "#131C21", border: "#23333A", fg: "#E4EEF2", fgDim: "#8298A2", accent: "#4FB6CE" },
|
||||
};
|
||||
if (PACK === "sky") {
|
||||
return {
|
||||
id: "bench-sky", name: "Bench sky", active: true, tokens,
|
||||
sky: { ray: "rgba(255,216,142,.55)", cloud: "255,240,212", cloudLit: "242,206,140", rayAlpha: 0.85, cloudAlpha: 0.4 },
|
||||
};
|
||||
}
|
||||
if (PACK === "photo") {
|
||||
return {
|
||||
id: "bench-photo", name: "Bench photo", active: true, tokens,
|
||||
background: { image: true, focusX: 0.5, focusY: 0.5, homeOpacity: 0.9, taskOpacity: 0.5, overlayStrength: 0.4 },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
class BenchPort extends MockPort {
|
||||
private readonly subs = new Set<(e: WireEvent) => void>();
|
||||
|
||||
async themes(): Promise<ThemePack[]> {
|
||||
const pack = benchPack();
|
||||
return pack ? [pack] : super.themes();
|
||||
}
|
||||
|
||||
async saveAppearance(look: Appearance) {
|
||||
saves.push(look);
|
||||
inFlight++;
|
||||
@@ -161,8 +191,9 @@ const JOBS = Number(query.get("jobs") ?? 0);
|
||||
// ?treems= holds every tree read back until that long after load, so the
|
||||
// window can be seen before the kernel has named a single folder.
|
||||
const TREE_AT = performance.now() + Number(query.get("treems") ?? 0);
|
||||
// ?panes=0 opens the window with no conversation open.
|
||||
const NO_PANES = query.get("panes") === "0";
|
||||
// ?panes=0 opens the window with no conversation open; ?panes=N opens N.
|
||||
const PANES = query.has("panes") ? Number(query.get("panes")) : 1;
|
||||
const NO_PANES = PANES === 0;
|
||||
|
||||
class BenchHub extends MockHub {
|
||||
readonly feeds = new Map<string, BenchPort>();
|
||||
@@ -174,7 +205,17 @@ class BenchHub extends MockHub {
|
||||
}
|
||||
|
||||
runtimes(): Promise<RuntimeView[]> {
|
||||
return NO_PANES ? Promise.resolve([]) : super.runtimes();
|
||||
if (NO_PANES) return Promise.resolve([]);
|
||||
if (PANES <= 1) return super.runtimes();
|
||||
return Promise.resolve(
|
||||
Array.from({ length: PANES }, (_, i) => ({
|
||||
id: `r${i + 1}`,
|
||||
base: i === 0 ? "" : `/rt/r${i + 1}`,
|
||||
root: "~/projects/DeepSeek-Reasonix",
|
||||
name: "DeepSeek-Reasonix",
|
||||
sessionPath: `/sessions/pane-${i + 1}.jsonl`,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
tree(): Promise<TreeWorkspace[]> {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Ink contrast per tier and scheme. Each tier's inks must be at least as legible
|
||||
// as the default palette stepped once, and as the recorded floors below (ratios
|
||||
// on --page; --ghost shares --faint's), and every ink must clear AA on both grounds.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=1";
|
||||
const STEPS = { "--text": [3.0, 10.1], "--muted": [3.5, 7.5], "--faint": [3.5, 7.0], "--ghost": [3.5, 7.0] };
|
||||
const INKS = Object.keys(STEPS);
|
||||
const GROUNDS = ["--page", "--surface"];
|
||||
const TIERS = ["soft", "normal", "strong"];
|
||||
const FLOOR = {
|
||||
"light/soft": { "--text": 11.69, "--muted": 7.63, "--faint": 5.76 },
|
||||
"light/normal": { "--text": 14.07, "--muted": 10.22, "--faint": 7.86 },
|
||||
"light/strong": { "--text": 17.86, "--muted": 13.55, "--faint": 10.42 },
|
||||
"dark/soft": { "--text": 11.48, "--muted": 7.48, "--faint": 5.63 },
|
||||
"dark/normal": { "--text": 13.92, "--muted": 9.79, "--faint": 7.57 },
|
||||
"dark/strong": { "--text": 18.01, "--muted": 13.16, "--faint": 9.92 },
|
||||
};
|
||||
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1200, height: 800 }, locale: "zh-CN" });
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(PAGE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose");
|
||||
|
||||
const measured = await page.evaluate(({ STEPS, INKS, GROUNDS, TIERS }) => {
|
||||
const root = document.documentElement;
|
||||
const probe = document.createElement("div");
|
||||
document.body.append(probe);
|
||||
const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
|
||||
const rgb = (css) => {
|
||||
ctx.clearRect(0, 0, 1, 1);
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillStyle = css;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
return [...ctx.getImageData(0, 0, 1, 1).data].slice(0, 3);
|
||||
};
|
||||
const lum = (c) => {
|
||||
const [r, g, b] = c.map((v) => {
|
||||
const s = v / 255;
|
||||
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
const ratio = (a, b) => {
|
||||
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
const read = (name) => {
|
||||
probe.style.color = `var(${name})`;
|
||||
return getComputedStyle(probe).color;
|
||||
};
|
||||
const out = {};
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
root.dataset.theme = scheme;
|
||||
delete root.dataset.contrast;
|
||||
const base = Object.fromEntries(INKS.map((n) => [n, read(n === "--ghost" ? "--faint" : n)]));
|
||||
const grounds = Object.fromEntries(GROUNDS.map((g) => [g, rgb(read(g))]));
|
||||
for (const tier of TIERS) {
|
||||
const k = TIERS.indexOf(tier);
|
||||
root.dataset.contrast = tier;
|
||||
for (const ink of INKS) {
|
||||
const by = k === 0 ? 0 : STEPS[ink][k - 1];
|
||||
probe.style.color = by ? `oklch(from ${base[ink]} calc(l ${scheme === "light" ? "-" : "+"} ${(by / 100).toFixed(3)}) c h)` : base[ink];
|
||||
const ref = rgb(getComputedStyle(probe).color);
|
||||
const got = rgb(read(ink));
|
||||
for (const g of GROUNDS) out[`${scheme}/${tier}/${ink}/${g}`] = { ref: ratio(ref, grounds[g]), got: ratio(got, grounds[g]) };
|
||||
}
|
||||
}
|
||||
}
|
||||
probe.remove();
|
||||
return out;
|
||||
}, { STEPS, INKS, GROUNDS, TIERS });
|
||||
|
||||
for (const [key, { ref, got }] of Object.entries(measured)) {
|
||||
const [scheme, tier, ink, ground] = key.split("/");
|
||||
const floor = ground === "--page" ? (FLOOR[`${scheme}/${tier}`][ink === "--ghost" ? "--faint" : ink] ?? 0) : 0;
|
||||
check(`${key} not below the stepped palette or its floor`, got >= Math.max(ref, floor) - 0.005, `${got.toFixed(2)} vs ${Math.max(ref, floor).toFixed(2)}`);
|
||||
check(`${key} clears AA`, got >= 4.5, got.toFixed(2));
|
||||
}
|
||||
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
await page.emulateMedia({ colorScheme: scheme, contrast: "more" });
|
||||
const same = await page.evaluate((scheme) => {
|
||||
const root = document.documentElement;
|
||||
root.dataset.theme = scheme;
|
||||
delete root.dataset.contrast;
|
||||
const p = document.createElement("div");
|
||||
document.body.append(p);
|
||||
const c = (n) => ((p.style.color = `var(${n})`), getComputedStyle(p).color);
|
||||
const r = [c("--faint"), c("--ghost")];
|
||||
p.remove();
|
||||
return r;
|
||||
}, scheme);
|
||||
check(`${scheme} under prefers-contrast: more keeps ghost on faint`, same[0] === same[1], same.join(" / "));
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nevery tier holds");
|
||||
@@ -0,0 +1,99 @@
|
||||
// Composer control tooltips: the box must hold its own text, must not sit under a
|
||||
// dimmed ancestor, and must stay readable. All three are layout/paint questions jsdom cannot answer.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const BOX = 'textarea[role="combobox"]';
|
||||
const fails = [];
|
||||
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
// Controls are found by structure, never by label: the label is translated,
|
||||
// so a lookup by its wording matches one language and skips the other.
|
||||
const CONTROLS = { "prompt.refine": '[data-action="prompt.refine"]', attach: "button.attach" };
|
||||
|
||||
const measure = (page, selector) =>
|
||||
page.evaluate((selector) => {
|
||||
const button = document.querySelector(`.compose ${selector}`);
|
||||
const tip = button?.querySelector(".studio-control-tip");
|
||||
if (!button || !tip) return null;
|
||||
let opacity = 1;
|
||||
for (let el = tip; el; el = el.parentElement) opacity *= parseFloat(getComputedStyle(el).opacity);
|
||||
const ink = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
|
||||
const rgba = (css) => {
|
||||
ink.clearRect(0, 0, 1, 1);
|
||||
ink.fillStyle = "#000";
|
||||
ink.fillStyle = css;
|
||||
ink.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b, a] = ink.getImageData(0, 0, 1, 1).data;
|
||||
return [r, g, b, a / 255];
|
||||
};
|
||||
const lum = ([r, g, b]) => {
|
||||
const f = (c) => ((c /= 255) <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
||||
};
|
||||
const over = (top, bottom) => {
|
||||
const a = top[3];
|
||||
return [0, 1, 2].map((i) => top[i] * a + bottom[i] * (1 - a)).concat(1);
|
||||
};
|
||||
const bg = rgba(getComputedStyle(tip).backgroundColor);
|
||||
const ratios = [...tip.children].map((child) => {
|
||||
const fg = over(rgba(getComputedStyle(child).color), bg);
|
||||
const [hi, lo] = [lum(fg), lum(bg)].sort((x, y) => y - x);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
});
|
||||
const box = tip.getBoundingClientRect();
|
||||
const spill = [...tip.children].map((child) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(child);
|
||||
return Math.max(0, range.getBoundingClientRect().right - box.right);
|
||||
});
|
||||
return {
|
||||
opacity,
|
||||
visibility: getComputedStyle(tip).visibility,
|
||||
minRatio: Math.min(...ratios),
|
||||
spill: Math.max(...spill),
|
||||
scroll: tip.scrollWidth - tip.clientWidth,
|
||||
left: box.left,
|
||||
right: box.right,
|
||||
width: box.width,
|
||||
viewport: innerWidth,
|
||||
disabled: button.disabled,
|
||||
};
|
||||
}, selector);
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const lang of ["zh", "en"]) {
|
||||
for (const width of [1440, 420]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
await page.goto(`${BASE}?pref=${lang}&turns=2`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
for (const [text, control] of [["", "prompt.refine"], ["1111", "prompt.refine"], ["", "attach"]]) {
|
||||
await page.fill(BOX, text);
|
||||
const tag = `${scheme}/${lang}/${width}px/${control}/${text ? "有字" : "空"}`;
|
||||
if (!(await page.$(`.compose ${CONTROLS[control]}`))) { check(`${tag}:找得到按钮`, false); continue; }
|
||||
await page.hover(`.compose ${CONTROLS[control]}`, { force: true });
|
||||
await page.waitForTimeout(250);
|
||||
const m = await measure(page, CONTROLS[control]);
|
||||
if (!m) { check(`${tag}:找得到提示`, false); continue; }
|
||||
check(`${tag}:提示不在半透明的祖先之下`, m.opacity === 1 && m.visibility === "visible", `有效不透明度 ${m.opacity.toFixed(2)}`);
|
||||
check(`${tag}:文字不溢出提示框`, m.spill <= 0.5 && m.scroll <= 0, `溢出 ${m.spill.toFixed(1)}px,scroll ${m.scroll}px`);
|
||||
check(`${tag}:提示框留在窗口内`, m.left >= 0 && m.right <= m.viewport, `${Math.round(m.left)}–${Math.round(m.right)} / ${m.viewport}`);
|
||||
check(`${tag}:正文对比度达 AA`, m.minRatio >= 4.5, `${m.minRatio.toFixed(2)}:1`);
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} 项不合格`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// 滚轮轨迹的探针:在一份很高的两轮转录里一路向上滚,scrollTop 在滚轮向上时
|
||||
// 不许往下走、单步不许比滚轮多走、scrollHeight 不许缩水。
|
||||
//
|
||||
// 目前只报告、不判红:`.call` 的 `content-visibility: auto` 配上
|
||||
// `contain-intrinsic-size: auto 216px`,会在块第一次进入视口时把预估高度改成
|
||||
// 实测高度,scrollHeight 随之缩水(12975 → 11217),内容在手指下跳动。修复
|
||||
// 落地后把 `WHEEL_STRICT` 的默认值翻成 1,这条探针就成为那次修复的验收。
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const STEP = Number(process.env.WHEEL_STEP ?? 100);
|
||||
const TURNS = Number(process.env.WHEEL_TURNS ?? 2);
|
||||
const CALLS = Number(process.env.WHEEL_CALLS ?? 6);
|
||||
const WAIT = Number(process.env.WHEEL_WAIT ?? 120);
|
||||
const STRICT = (process.env.WHEEL_STRICT ?? "0") === "1";
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
const findings = [];
|
||||
const pageErrors = [];
|
||||
page.on("pageerror", (e) => pageErrors.push("page error: " + e.message));
|
||||
|
||||
await page.goto(PAGE + (PAGE.includes("?") ? "&" : "?") + "queue=1", { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app", { timeout: 15000 });
|
||||
await page.waitForTimeout(700);
|
||||
|
||||
const code = "```go\n" + Array.from({ length: 40 }, (_, i) => `func f${i}(x int) int { return x*${i} + 1 } // line ${i}`).join("\n") + "\n```\n\n";
|
||||
const table = "| a | b | c |\n|---|---|---|\n" + Array.from({ length: 12 }, (_, i) => `| ${i} | row ${i} | value ${i * 7} |`).join("\n") + "\n\n";
|
||||
const prose = Array.from({ length: 14 }, (_, i) => `Paragraph ${i}: ` + "the quick brown fox jumps over the lazy dog, ".repeat(9)).join("\n\n") + "\n\n";
|
||||
const reply = "# Heading\n\n" + prose + code + table + prose + code + "- one\n- two\n- three\n\n" + prose;
|
||||
|
||||
await page.evaluate(async ({ n, reply, calls }) => {
|
||||
const frame = () => new Promise((r) => requestAnimationFrame(r));
|
||||
for (let i = 0; i < n; i++) {
|
||||
window.__feed({ kind: "turn_started", authoredTurn: i + 1, msgIndex: i * 2, text: `Question ${i}` });
|
||||
for (let k = 0; k < (i === 0 ? calls : 6); k++) {
|
||||
const id = `t${i}-${k}`;
|
||||
const args = JSON.stringify({ path: `pkg/f${k}.go` });
|
||||
window.__feed({ kind: "tool_dispatch", tool: { id, name: "edit_file", args } });
|
||||
window.__feed({ kind: "tool_result", tool: { id, name: "edit_file", args, output: "ok", durationMs: 210, added: 4, removed: 2 } });
|
||||
}
|
||||
window.__feed({ kind: "text", text: reply });
|
||||
window.__feed({ kind: "message" });
|
||||
window.__feed({ kind: "turn_done" });
|
||||
await frame();
|
||||
}
|
||||
await frame();
|
||||
}, { n: TURNS, reply, calls: CALLS });
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
const flow = page.locator('[data-pane="flow"]').first();
|
||||
const box = await flow.boundingBox();
|
||||
const cdp = await ctx.newCDPSession(page);
|
||||
const read = () => flow.evaluate((el) => ({ top: el.scrollTop, h: el.scrollHeight }));
|
||||
|
||||
const start = await read();
|
||||
const rows = [];
|
||||
let prev = start;
|
||||
for (let i = 0; i < 400; i++) {
|
||||
await cdp.send("Input.dispatchMouseEvent", { type: "mouseWheel", x: box.x + box.width / 2, y: box.y + box.height / 2, deltaX: 0, deltaY: -STEP });
|
||||
await page.waitForTimeout(WAIT);
|
||||
const p = await read();
|
||||
rows.push({ i, d: p.top - prev.top, ...p });
|
||||
prev = p;
|
||||
if (p.top <= 0) break;
|
||||
}
|
||||
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : STRICT ? "FAIL" : "NOTE"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) findings.push(name);
|
||||
};
|
||||
const down = rows.filter((r) => r.d > 2);
|
||||
const jump = rows.filter((r) => r.d < -STEP * 1.5);
|
||||
const minH = Math.min(start.h, ...rows.map((r) => r.h));
|
||||
console.log(`start top=${start.top} scrollHeight=${start.h}; ${rows.length} wheel steps; scrollHeight min=${minH} end=${prev.h}`);
|
||||
check("never moves down while the wheel goes up", down.length === 0, down.slice(0, 6).map((r) => `#${r.i}:${r.d}`).join(" "));
|
||||
check("no step exceeds the wheel", jump.length === 0, jump.slice(0, 6).map((r) => `#${r.i}:${r.d}`).join(" "));
|
||||
check("scrollHeight does not shrink", minH >= start.h - 4, `${start.h} -> ${minH}`);
|
||||
|
||||
await browser.close();
|
||||
for (const e of pageErrors) console.log(e);
|
||||
process.exit(pageErrors.length || (STRICT && findings.length) ? 1 : 0);
|
||||
Generated
+125
-138
@@ -60,14 +60,14 @@ importers:
|
||||
specifier: ^6.43.13
|
||||
version: 6.43.13
|
||||
'@lezer/highlight':
|
||||
specifier: ^1.2.4
|
||||
version: 1.2.4
|
||||
specifier: ^1.2.5
|
||||
version: 1.2.5
|
||||
codemirror:
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
katex:
|
||||
specifier: ^0.18.9
|
||||
version: 0.18.9
|
||||
specifier: ^0.18.10
|
||||
version: 0.18.10
|
||||
react:
|
||||
specifier: ^19.3.0
|
||||
version: 19.3.0
|
||||
@@ -116,7 +116,7 @@ importers:
|
||||
version: 19.3.0(@types/[email protected])
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.1.1
|
||||
version: 6.1.1([email protected].0([email protected]))
|
||||
version: 6.1.1([email protected].2([email protected]))
|
||||
jsdom:
|
||||
specifier: ^30.1.1
|
||||
version: 30.1.1
|
||||
@@ -127,11 +127,11 @@ importers:
|
||||
specifier: ^7.0.2
|
||||
version: 7.0.2
|
||||
vite:
|
||||
specifier: ^8.3.0
|
||||
version: 8.3.0([email protected])
|
||||
specifier: ^8.3.2
|
||||
version: 8.3.2([email protected])
|
||||
vitest:
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1([email protected])([email protected].0([email protected]))
|
||||
specifier: ^5.0.3
|
||||
version: 5.0.3([email protected])([email protected].2([email protected]))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -453,8 +453,8 @@ packages:
|
||||
'@lezer/[email protected]':
|
||||
resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==}
|
||||
|
||||
'@lezer/[email protected].4':
|
||||
resolution: {integrity: sha512-4UeRVFO0lsBaM7GA32zOqbmqNPme9jedo8Ey+5c5656MU3MLitMLIA8aCZTYjUMEpEsuSeXEm78Uea6Iq3bvSg==}
|
||||
'@lezer/[email protected].5':
|
||||
resolution: {integrity: sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==}
|
||||
|
||||
'@lezer/[email protected]':
|
||||
resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
|
||||
@@ -489,101 +489,101 @@ packages:
|
||||
'@marijn/[email protected]':
|
||||
resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
|
||||
|
||||
'@oxc-project/[email protected]47.0':
|
||||
resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==}
|
||||
'@oxc-project/[email protected]52.0':
|
||||
resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==}
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
resolution: {integrity: sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==}
|
||||
'@rolldown/[email protected].12':
|
||||
resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -799,8 +799,8 @@ packages:
|
||||
oxc-transform-react:
|
||||
optional: true
|
||||
|
||||
'@vitest/[email protected].1':
|
||||
resolution: {integrity: sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==}
|
||||
'@vitest/[email protected].3':
|
||||
resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -810,8 +810,8 @@ packages:
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/[email protected].1':
|
||||
resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==}
|
||||
'@vitest/[email protected].3':
|
||||
resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
@@ -1051,8 +1051,8 @@ packages:
|
||||
resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
|
||||
hasBin: true
|
||||
|
||||
[email protected].9:
|
||||
resolution: {integrity: sha512-8ad9RyoKsb/g8/yLFE+KAlP+DhbCTRUNi/V9XGsxn0R+trJJltNwzcDNo0q/DEkOy5fQUQTAQyCXCYSE+OakTQ==}
|
||||
[email protected].10:
|
||||
resolution: {integrity: sha512-/B6p9eY9DX7aHBfpkHdpirDTZ5QH9xTwL9y837PWuzuv41O5jFdqNQwVQEQsimY0/iVNkwBY4+4hMfhQ7d4Dbw==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
@@ -1396,8 +1396,8 @@ packages:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected].6:
|
||||
resolution: {integrity: sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==}
|
||||
[email protected].12:
|
||||
resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
@@ -1408,9 +1408,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1418,9 +1415,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
|
||||
|
||||
@@ -1511,8 +1505,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||
|
||||
[email protected].0:
|
||||
resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==}
|
||||
[email protected].2:
|
||||
resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -1554,20 +1548,20 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
[email protected].1:
|
||||
resolution: {integrity: sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==}
|
||||
[email protected].3:
|
||||
resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==}
|
||||
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 5.0.1
|
||||
'@vitest/browser-preview': 5.0.1
|
||||
'@vitest/browser-playwright': 5.0.3
|
||||
'@vitest/browser-preview': 5.0.3
|
||||
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
|
||||
'@vitest/coverage-istanbul': 5.0.1
|
||||
'@vitest/coverage-v8': 5.0.1
|
||||
'@vitest/ui': 5.0.1
|
||||
'@vitest/coverage-istanbul': 5.0.3
|
||||
'@vitest/coverage-v8': 5.0.3
|
||||
'@vitest/ui': 5.0.3
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -1621,9 +1615,9 @@ packages:
|
||||
resolution: {integrity: sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==}
|
||||
engines: {node: ^22.14.0 || >=24.0.0}
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
why-is-node-running@3.2.1:
|
||||
resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==}
|
||||
engines: {node: '>=20.11'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
@@ -1771,7 +1765,7 @@ snapshots:
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.6
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
@@ -1789,7 +1783,7 @@ snapshots:
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.6
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
'@lezer/yaml': 1.0.4
|
||||
|
||||
@@ -1798,7 +1792,7 @@ snapshots:
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
style-mod: 4.1.4
|
||||
|
||||
@@ -1943,41 +1937,41 @@ snapshots:
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/[email protected].4':
|
||||
'@lezer/[email protected].5':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/[email protected]':
|
||||
@@ -1987,79 +1981,79 @@ snapshots:
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/[email protected]':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.4
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@marijn/[email protected]': {}
|
||||
|
||||
'@oxc-project/[email protected]47.0': {}
|
||||
'@oxc-project/[email protected]52.0': {}
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected].6':
|
||||
'@rolldown/[email protected].12':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]': {}
|
||||
@@ -2194,21 +2188,21 @@ snapshots:
|
||||
|
||||
'@ungap/[email protected]': {}
|
||||
|
||||
'@vitejs/[email protected]([email protected].0([email protected]))':
|
||||
'@vitejs/[email protected]([email protected].2([email protected]))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.3.0([email protected])
|
||||
vite: 8.3.2([email protected])
|
||||
|
||||
'@vitest/[email protected].1([email protected].0([email protected]))':
|
||||
'@vitest/[email protected].3([email protected].2([email protected]))':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
'@vitest/spy': 5.0.1
|
||||
'@vitest/spy': 5.0.3
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 1.4.2
|
||||
optionalDependencies:
|
||||
vite: 8.3.0([email protected])
|
||||
vite: 8.3.2([email protected])
|
||||
|
||||
'@vitest/[email protected].1': {}
|
||||
'@vitest/[email protected].3': {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -2519,7 +2513,7 @@ snapshots:
|
||||
dependencies:
|
||||
commander: 8.3.0
|
||||
|
||||
[email protected].9:
|
||||
[email protected].10:
|
||||
dependencies:
|
||||
commander: 15.0.0
|
||||
|
||||
@@ -3117,26 +3111,26 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected].6:
|
||||
[email protected].12:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.147.0
|
||||
'@oxc-project/types': 0.152.0
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
optionalDependencies:
|
||||
'@rolldown/binding-android-arm-eabi': 1.2.6
|
||||
'@rolldown/binding-android-arm64': 1.2.6
|
||||
'@rolldown/binding-darwin-arm64': 1.2.6
|
||||
'@rolldown/binding-darwin-x64': 1.2.6
|
||||
'@rolldown/binding-freebsd-x64': 1.2.6
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.2.6
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.2.6
|
||||
'@rolldown/binding-linux-arm64-musl': 1.2.6
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.2.6
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.2.6
|
||||
'@rolldown/binding-linux-x64-gnu': 1.2.6
|
||||
'@rolldown/binding-linux-x64-musl': 1.2.6
|
||||
'@rolldown/binding-openharmony-arm64': 1.2.6
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.2.6
|
||||
'@rolldown/binding-win32-x64-msvc': 1.2.6
|
||||
'@rolldown/binding-android-arm-eabi': 1.2.12
|
||||
'@rolldown/binding-android-arm64': 1.2.12
|
||||
'@rolldown/binding-darwin-arm64': 1.2.12
|
||||
'@rolldown/binding-darwin-x64': 1.2.12
|
||||
'@rolldown/binding-freebsd-x64': 1.2.12
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.2.12
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.2.12
|
||||
'@rolldown/binding-linux-arm64-musl': 1.2.12
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.2.12
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.2.12
|
||||
'@rolldown/binding-linux-x64-gnu': 1.2.12
|
||||
'@rolldown/binding-linux-x64-musl': 1.2.12
|
||||
'@rolldown/binding-openharmony-arm64': 1.2.12
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.2.12
|
||||
'@rolldown/binding-win32-x64-msvc': 1.2.12
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -3144,14 +3138,10 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -3279,21 +3269,21 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
[email protected].0([email protected]):
|
||||
[email protected].2([email protected]):
|
||||
dependencies:
|
||||
lightningcss: 1.33.0
|
||||
picomatch: 4.0.7
|
||||
postcss: 8.5.28
|
||||
rolldown: 1.2.6
|
||||
rolldown: 1.2.12
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
esbuild: 0.28.2
|
||||
fsevents: 2.3.3
|
||||
|
||||
[email protected].1([email protected])([email protected].0([email protected])):
|
||||
[email protected].3([email protected])([email protected].2([email protected])):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/mocker': 5.0.1([email protected].0([email protected]))
|
||||
'@vitest/mocker': 5.0.3([email protected].2([email protected]))
|
||||
chai: 6.2.2
|
||||
es-module-lexer: 2.3.2
|
||||
expect-type: 1.4.0
|
||||
@@ -3304,8 +3294,8 @@ snapshots:
|
||||
tinybench: 6.1.4
|
||||
tinyexec: 1.3.0
|
||||
tinyglobby: 0.2.17
|
||||
vite: 8.3.0([email protected])
|
||||
why-is-node-running: 2.3.0
|
||||
vite: 8.3.2([email protected])
|
||||
why-is-node-running: 3.2.1
|
||||
optionalDependencies:
|
||||
jsdom: 30.1.1
|
||||
transitivePeerDependencies:
|
||||
@@ -3339,10 +3329,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
why-is-node-running@3.2.1: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
|
||||
Loaded 100 of 1488 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user