refactor: 프론트엔드 리펙토링

This commit is contained in:
donghyeon-ka
2026-09-18 22:05:42 +09:00
parent 5cc41467ae
commit ec7f20e2ee
100 changed files with 6005 additions and 2867 deletions
@@ -0,0 +1,224 @@
body, html {
margin:0; padding: 0;
height: 100%;
}
body {
font-family: Helvetica Neue, Helvetica, Arial;
font-size: 14px;
color:#333;
}
.small { font-size: 12px; }
*, *:after, *:before {
-webkit-box-sizing:border-box;
-moz-box-sizing:border-box;
box-sizing:border-box;
}
h1 { font-size: 20px; margin: 0;}
h2 { font-size: 14px; }
pre {
font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace;
margin: 0;
padding: 0;
-moz-tab-size: 2;
-o-tab-size: 2;
tab-size: 2;
}
a { color:#0074D9; text-decoration:none; }
a:hover { text-decoration:underline; }
.strong { font-weight: bold; }
.space-top1 { padding: 10px 0 0 0; }
.pad2y { padding: 20px 0; }
.pad1y { padding: 10px 0; }
.pad2x { padding: 0 20px; }
.pad2 { padding: 20px; }
.pad1 { padding: 10px; }
.space-left2 { padding-left:55px; }
.space-right2 { padding-right:20px; }
.center { text-align:center; }
.clearfix { display:block; }
.clearfix:after {
content:'';
display:block;
height:0;
clear:both;
visibility:hidden;
}
.fl { float: left; }
@media only screen and (max-width:640px) {
.col3 { width:100%; max-width:100%; }
.hide-mobile { display:none!important; }
}
.quiet {
color: #7f7f7f;
color: rgba(0,0,0,0.5);
}
.quiet a { opacity: 0.7; }
.fraction {
font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
font-size: 10px;
color: #555;
background: #E8E8E8;
padding: 4px 5px;
border-radius: 3px;
vertical-align: middle;
}
div.path a:link, div.path a:visited { color: #333; }
table.coverage {
border-collapse: collapse;
margin: 10px 0 0 0;
padding: 0;
}
table.coverage td {
margin: 0;
padding: 0;
vertical-align: top;
}
table.coverage td.line-count {
text-align: right;
padding: 0 5px 0 20px;
}
table.coverage td.line-coverage {
text-align: right;
padding-right: 10px;
min-width:20px;
}
table.coverage td span.cline-any {
display: inline-block;
padding: 0 5px;
width: 100%;
}
.missing-if-branch {
display: inline-block;
margin-right: 5px;
border-radius: 3px;
position: relative;
padding: 0 4px;
background: #333;
color: yellow;
}
.skip-if-branch {
display: none;
margin-right: 10px;
position: relative;
padding: 0 4px;
background: #ccc;
color: white;
}
.missing-if-branch .typ, .skip-if-branch .typ {
color: inherit !important;
}
.coverage-summary {
border-collapse: collapse;
width: 100%;
}
.coverage-summary tr { border-bottom: 1px solid #bbb; }
.keyline-all { border: 1px solid #ddd; }
.coverage-summary td, .coverage-summary th { padding: 10px; }
.coverage-summary tbody { border: 1px solid #bbb; }
.coverage-summary td { border-right: 1px solid #bbb; }
.coverage-summary td:last-child { border-right: none; }
.coverage-summary th {
text-align: left;
font-weight: normal;
white-space: nowrap;
}
.coverage-summary th.file { border-right: none !important; }
.coverage-summary th.pct { }
.coverage-summary th.pic,
.coverage-summary th.abs,
.coverage-summary td.pct,
.coverage-summary td.abs { text-align: right; }
.coverage-summary td.file { white-space: nowrap; }
.coverage-summary td.pic { min-width: 120px !important; }
.coverage-summary tfoot td { }
.coverage-summary .sorter {
height: 10px;
width: 7px;
display: inline-block;
margin-left: 0.5em;
background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent;
}
.coverage-summary .sorted .sorter {
background-position: 0 -20px;
}
.coverage-summary .sorted-desc .sorter {
background-position: 0 -10px;
}
.status-line { height: 10px; }
/* yellow */
.cbranch-no { background: yellow !important; color: #111; }
/* dark red */
.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 }
.low .chart { border:1px solid #C21F39 }
.highlighted,
.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{
background: #C21F39 !important;
}
/* medium red */
.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE }
/* light red */
.low, .cline-no { background:#FCE1E5 }
/* light green */
.high, .cline-yes { background:rgb(230,245,208) }
/* medium green */
.cstat-yes { background:rgb(161,215,106) }
/* dark green */
.status-line.high, .high .cover-fill { background:rgb(77,146,33) }
.high .chart { border:1px solid rgb(77,146,33) }
/* dark yellow (gold) */
.status-line.medium, .medium .cover-fill { background: #f9cd0b; }
.medium .chart { border:1px solid #f9cd0b; }
/* light yellow */
.medium { background: #fff4c2; }
.cstat-skip { background: #ddd; color: #111; }
.fstat-skip { background: #ddd; color: #111 !important; }
.cbranch-skip { background: #ddd !important; color: #111; }
span.cline-neutral { background: #eaeaea; }
.coverage-summary td.empty {
opacity: .5;
padding-top: 4px;
padding-bottom: 4px;
line-height: 1;
color: #888;
}
.cover-fill, .cover-empty {
display:inline-block;
height: 12px;
}
.chart {
line-height: 0;
}
.cover-empty {
background: white;
}
.cover-full {
border-right: none !important;
}
pre.prettyprint {
border: none !important;
padding: 0 !important;
margin: 0 !important;
}
.com { color: #999 !important; }
.ignore-none { color: #999; font-weight: normal; }
.wrapper {
min-height: 100%;
height: auto !important;
height: 100%;
margin: 0 auto -48px;
}
.footer, .push {
height: 48px;
}
@@ -0,0 +1,87 @@
/* eslint-disable */
var jumpToCode = (function init() {
// Classes of code we would like to highlight in the file view
var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no'];
// Elements to highlight in the file listing view
var fileListingElements = ['td.pct.low'];
// We don't want to select elements that are direct descendants of another match
var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > `
// Selector that finds elements on the page to which we can jump
var selector =
fileListingElements.join(', ') +
', ' +
notSelector +
missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b`
// The NodeList of matching elements
var missingCoverageElements = document.querySelectorAll(selector);
var currentIndex;
function toggleClass(index) {
missingCoverageElements
.item(currentIndex)
.classList.remove('highlighted');
missingCoverageElements.item(index).classList.add('highlighted');
}
function makeCurrent(index) {
toggleClass(index);
currentIndex = index;
missingCoverageElements.item(index).scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center'
});
}
function goToPrevious() {
var nextIndex = 0;
if (typeof currentIndex !== 'number' || currentIndex === 0) {
nextIndex = missingCoverageElements.length - 1;
} else if (missingCoverageElements.length > 1) {
nextIndex = currentIndex - 1;
}
makeCurrent(nextIndex);
}
function goToNext() {
var nextIndex = 0;
if (
typeof currentIndex === 'number' &&
currentIndex < missingCoverageElements.length - 1
) {
nextIndex = currentIndex + 1;
}
makeCurrent(nextIndex);
}
return function jump(event) {
if (
document.getElementById('fileSearch') === document.activeElement &&
document.activeElement != null
) {
// if we're currently focused on the search input, we don't want to navigate
return;
}
switch (event.which) {
case 78: // n
case 74: // j
goToNext();
break;
case 66: // b
case 75: // k
case 80: // p
goToPrevious();
break;
}
};
})();
window.addEventListener('keydown', jumpToCode);
Binary file not shown.

After

Width:  |  Height:  |  Size: 445 B

@@ -0,0 +1,116 @@
<!doctype html>
<html lang="en">
<head>
<title>Code coverage report for All files</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="prettify.css" />
<link rel="stylesheet" href="base.css" />
<link rel="shortcut icon" type="image/x-icon" href="favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type='text/css'>
.coverage-summary .sorter {
background-image: url(sort-arrow-sprite.png);
}
</style>
</head>
<body>
<div class='wrapper'>
<div class='pad1'>
<h1>All files</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Statements</span>
<span class='fraction'>11/11</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Branches</span>
<span class='fraction'>8/8</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Functions</span>
<span class='fraction'>5/5</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Lines</span>
<span class='fraction'>11/11</span>
</div>
</div>
<p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
</p>
<template id="filterTemplate">
<div class="quiet">
Filter:
<input type="search" id="fileSearch">
</div>
</template>
</div>
<div class='status-line high'></div>
<div class="pad1">
<table class="coverage-summary">
<thead>
<tr>
<th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
<th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
<th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
<th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
<th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
<th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
<th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
</tr>
</thead>
<tbody><tr>
<td class="file high" data-value="reference-http-gateway.ts"><a href="reference-http-gateway.ts.html">reference-http-gateway.ts</a></td>
<td data-value="100" class="pic high">
<div class="chart"><div class="cover-fill cover-full" style="width: 100%"></div><div class="cover-empty" style="width: 0%"></div></div>
</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="11" class="abs high">11/11</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="8" class="abs high">8/8</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="5" class="abs high">5/5</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="11" class="abs high">11/11</td>
</tr>
</tbody>
</table>
</div>
<div class='push'></div><!-- for sticky footer -->
</div><!-- /wrapper -->
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-09-18T11:39:00.427Z
</div>
<script src="prettify.js"></script>
<script>
window.onload = function () {
prettyPrint();
};
</script>
<script src="sorter.js"></script>
<script src="block-navigation.js"></script>
</body>
</html>
@@ -0,0 +1 @@
.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,331 @@
<!doctype html>
<html lang="en">
<head>
<title>Code coverage report for reference-http-gateway.ts</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="prettify.css" />
<link rel="stylesheet" href="base.css" />
<link rel="shortcut icon" type="image/x-icon" href="favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type='text/css'>
.coverage-summary .sorter {
background-image: url(sort-arrow-sprite.png);
}
</style>
</head>
<body>
<div class='wrapper'>
<div class='pad1'>
<h1><a href="index.html">All files</a> reference-http-gateway.ts</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Statements</span>
<span class='fraction'>11/11</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Branches</span>
<span class='fraction'>8/8</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Functions</span>
<span class='fraction'>5/5</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Lines</span>
<span class='fraction'>11/11</span>
</div>
</div>
<p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
</p>
<template id="filterTemplate">
<div class="quiet">
Filter:
<input type="search" id="fileSearch">
</div>
</template>
</div>
<div class='status-line high'></div>
<pre><table class="coverage">
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
<a name='L2'></a><a href='#L2'>2</a>
<a name='L3'></a><a href='#L3'>3</a>
<a name='L4'></a><a href='#L4'>4</a>
<a name='L5'></a><a href='#L5'>5</a>
<a name='L6'></a><a href='#L6'>6</a>
<a name='L7'></a><a href='#L7'>7</a>
<a name='L8'></a><a href='#L8'>8</a>
<a name='L9'></a><a href='#L9'>9</a>
<a name='L10'></a><a href='#L10'>10</a>
<a name='L11'></a><a href='#L11'>11</a>
<a name='L12'></a><a href='#L12'>12</a>
<a name='L13'></a><a href='#L13'>13</a>
<a name='L14'></a><a href='#L14'>14</a>
<a name='L15'></a><a href='#L15'>15</a>
<a name='L16'></a><a href='#L16'>16</a>
<a name='L17'></a><a href='#L17'>17</a>
<a name='L18'></a><a href='#L18'>18</a>
<a name='L19'></a><a href='#L19'>19</a>
<a name='L20'></a><a href='#L20'>20</a>
<a name='L21'></a><a href='#L21'>21</a>
<a name='L22'></a><a href='#L22'>22</a>
<a name='L23'></a><a href='#L23'>23</a>
<a name='L24'></a><a href='#L24'>24</a>
<a name='L25'></a><a href='#L25'>25</a>
<a name='L26'></a><a href='#L26'>26</a>
<a name='L27'></a><a href='#L27'>27</a>
<a name='L28'></a><a href='#L28'>28</a>
<a name='L29'></a><a href='#L29'>29</a>
<a name='L30'></a><a href='#L30'>30</a>
<a name='L31'></a><a href='#L31'>31</a>
<a name='L32'></a><a href='#L32'>32</a>
<a name='L33'></a><a href='#L33'>33</a>
<a name='L34'></a><a href='#L34'>34</a>
<a name='L35'></a><a href='#L35'>35</a>
<a name='L36'></a><a href='#L36'>36</a>
<a name='L37'></a><a href='#L37'>37</a>
<a name='L38'></a><a href='#L38'>38</a>
<a name='L39'></a><a href='#L39'>39</a>
<a name='L40'></a><a href='#L40'>40</a>
<a name='L41'></a><a href='#L41'>41</a>
<a name='L42'></a><a href='#L42'>42</a>
<a name='L43'></a><a href='#L43'>43</a>
<a name='L44'></a><a href='#L44'>44</a>
<a name='L45'></a><a href='#L45'>45</a>
<a name='L46'></a><a href='#L46'>46</a>
<a name='L47'></a><a href='#L47'>47</a>
<a name='L48'></a><a href='#L48'>48</a>
<a name='L49'></a><a href='#L49'>49</a>
<a name='L50'></a><a href='#L50'>50</a>
<a name='L51'></a><a href='#L51'>51</a>
<a name='L52'></a><a href='#L52'>52</a>
<a name='L53'></a><a href='#L53'>53</a>
<a name='L54'></a><a href='#L54'>54</a>
<a name='L55'></a><a href='#L55'>55</a>
<a name='L56'></a><a href='#L56'>56</a>
<a name='L57'></a><a href='#L57'>57</a>
<a name='L58'></a><a href='#L58'>58</a>
<a name='L59'></a><a href='#L59'>59</a>
<a name='L60'></a><a href='#L60'>60</a>
<a name='L61'></a><a href='#L61'>61</a>
<a name='L62'></a><a href='#L62'>62</a>
<a name='L63'></a><a href='#L63'>63</a>
<a name='L64'></a><a href='#L64'>64</a>
<a name='L65'></a><a href='#L65'>65</a>
<a name='L66'></a><a href='#L66'>66</a>
<a name='L67'></a><a href='#L67'>67</a>
<a name='L68'></a><a href='#L68'>68</a>
<a name='L69'></a><a href='#L69'>69</a>
<a name='L70'></a><a href='#L70'>70</a>
<a name='L71'></a><a href='#L71'>71</a>
<a name='L72'></a><a href='#L72'>72</a>
<a name='L73'></a><a href='#L73'>73</a>
<a name='L74'></a><a href='#L74'>74</a>
<a name='L75'></a><a href='#L75'>75</a>
<a name='L76'></a><a href='#L76'>76</a>
<a name='L77'></a><a href='#L77'>77</a>
<a name='L78'></a><a href='#L78'>78</a>
<a name='L79'></a><a href='#L79'>79</a>
<a name='L80'></a><a href='#L80'>80</a>
<a name='L81'></a><a href='#L81'>81</a>
<a name='L82'></a><a href='#L82'>82</a>
<a name='L83'></a><a href='#L83'>83</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">5x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">6x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">import {
defineFeatureHttpOperationForRoutes,
type FeatureHttpBinding,
} from "../../../adapters/http/index.ts";
import {
CREATE_REFERENCE_RESOURCE_CONTRACT,
GET_REFERENCE_RESOURCE_CONTRACT,
LIST_REFERENCE_RESOURCES_CONTRACT,
} from "../contracts/reference-feature-contract-contribution.ts";
import {
REFERENCE_FEATURE_CONTRACT,
} from "../contracts/reference-feature-contract.ts";
import {
mapReferenceResourceListPayload,
mapReferenceResourcePayload,
} from "../contracts/reference-mapper.ts";
import type {
ReferenceGateway,
} from "../application/reference-feature-api.ts";
&nbsp;
export type ReferenceFeatureRouteId =
keyof typeof REFERENCE_FEATURE_CONTRACT.routes;
&nbsp;
const defineReferenceHttpOperation =
defineFeatureHttpOperationForRoutes&lt;ReferenceFeatureRouteId&gt;();
&nbsp;
export const REFERENCE_HTTP_OPERATIONS = Object.freeze({
LIST_REFERENCE_RESOURCES: defineReferenceHttpOperation({
contract: LIST_REFERENCE_RESOURCES_CONTRACT,
routeId: "REFERENCE_RESOURCE_LIST",
mapSuccess: mapReferenceResourceListPayload,
}),
CREATE_REFERENCE_RESOURCE: defineReferenceHttpOperation({
contract: CREATE_REFERENCE_RESOURCE_CONTRACT,
routeId: "REFERENCE_RESOURCE_LIST",
mapSuccess: mapReferenceResourcePayload,
mapProblem(problem, metadata) {
if (metadata.status === 409) {
return Object.freeze({
kind: "CONFLICT" as const,
code: problem.code ?? "REFERENCE_RESOURCE_CONFLICT",
});
}
if (metadata.status === 422) {
return Object.freeze({
kind: "VALIDATION_REJECTED" as const,
code: problem.code ?? "REFERENCE_RESOURCE_REJECTED",
});
}
return undefined;
},
}),
GET_REFERENCE_RESOURCE: defineReferenceHttpOperation({
contract: GET_REFERENCE_RESOURCE_CONTRACT,
routeId: "REFERENCE_RESOURCE_DETAIL",
mapSuccess: mapReferenceResourcePayload,
}),
} as const);
&nbsp;
export type ReferenceHttpBinding = FeatureHttpBinding&lt;
typeof REFERENCE_HTTP_OPERATIONS
&gt;;
&nbsp;
export function createReferenceHttpGateway(
http: ReferenceHttpBinding,
): ReferenceGateway {
return Object.freeze({
list(filters, context) {
return http.execute("LIST_REFERENCE_RESOURCES", filters, context);
},
create(command, context) {
return http.execute("CREATE_REFERENCE_RESOURCE", command, context);
},
get(resourceId, context) {
return http.execute(
"GET_REFERENCE_RESOURCE",
Object.freeze({ resourceId }),
context,
);
},
});
}
&nbsp;</pre></td></tr></table></pre>
<div class='push'></div><!-- for sticky footer -->
</div><!-- /wrapper -->
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-09-18T11:39:00.427Z
</div>
<script src="prettify.js"></script>
<script>
window.onload = function () {
prettyPrint();
};
</script>
<script src="sorter.js"></script>
<script src="block-navigation.js"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 B

@@ -0,0 +1,210 @@
/* eslint-disable */
var addSorting = (function() {
'use strict';
var cols,
currentSort = {
index: 0,
desc: false
};
// returns the summary table element
function getTable() {
return document.querySelector('.coverage-summary');
}
// returns the thead element of the summary table
function getTableHeader() {
return getTable().querySelector('thead tr');
}
// returns the tbody element of the summary table
function getTableBody() {
return getTable().querySelector('tbody');
}
// returns the th element for nth column
function getNthColumn(n) {
return getTableHeader().querySelectorAll('th')[n];
}
function onFilterInput() {
const searchValue = document.getElementById('fileSearch').value;
const rows = document.getElementsByTagName('tbody')[0].children;
// Try to create a RegExp from the searchValue. If it fails (invalid regex),
// it will be treated as a plain text search
let searchRegex;
try {
searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive
} catch (error) {
searchRegex = null;
}
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
let isMatch = false;
if (searchRegex) {
// If a valid regex was created, use it for matching
isMatch = searchRegex.test(row.textContent);
} else {
// Otherwise, fall back to the original plain text search
isMatch = row.textContent
.toLowerCase()
.includes(searchValue.toLowerCase());
}
row.style.display = isMatch ? '' : 'none';
}
}
// loads the search box
function addSearchBox() {
var template = document.getElementById('filterTemplate');
var templateClone = template.content.cloneNode(true);
templateClone.getElementById('fileSearch').oninput = onFilterInput;
template.parentElement.appendChild(templateClone);
}
// loads all columns
function loadColumns() {
var colNodes = getTableHeader().querySelectorAll('th'),
colNode,
cols = [],
col,
i;
for (i = 0; i < colNodes.length; i += 1) {
colNode = colNodes[i];
col = {
key: colNode.getAttribute('data-col'),
sortable: !colNode.getAttribute('data-nosort'),
type: colNode.getAttribute('data-type') || 'string'
};
cols.push(col);
if (col.sortable) {
col.defaultDescSort = col.type === 'number';
colNode.innerHTML =
colNode.innerHTML + '<span class="sorter"></span>';
}
}
return cols;
}
// attaches a data attribute to every tr element with an object
// of data values keyed by column name
function loadRowData(tableRow) {
var tableCols = tableRow.querySelectorAll('td'),
colNode,
col,
data = {},
i,
val;
for (i = 0; i < tableCols.length; i += 1) {
colNode = tableCols[i];
col = cols[i];
val = colNode.getAttribute('data-value');
if (col.type === 'number') {
val = Number(val);
}
data[col.key] = val;
}
return data;
}
// loads all row data
function loadData() {
var rows = getTableBody().querySelectorAll('tr'),
i;
for (i = 0; i < rows.length; i += 1) {
rows[i].data = loadRowData(rows[i]);
}
}
// sorts the table using the data for the ith column
function sortByIndex(index, desc) {
var key = cols[index].key,
sorter = function(a, b) {
a = a.data[key];
b = b.data[key];
return a < b ? -1 : a > b ? 1 : 0;
},
finalSorter = sorter,
tableBody = document.querySelector('.coverage-summary tbody'),
rowNodes = tableBody.querySelectorAll('tr'),
rows = [],
i;
if (desc) {
finalSorter = function(a, b) {
return -1 * sorter(a, b);
};
}
for (i = 0; i < rowNodes.length; i += 1) {
rows.push(rowNodes[i]);
tableBody.removeChild(rowNodes[i]);
}
rows.sort(finalSorter);
for (i = 0; i < rows.length; i += 1) {
tableBody.appendChild(rows[i]);
}
}
// removes sort indicators for current column being sorted
function removeSortIndicators() {
var col = getNthColumn(currentSort.index),
cls = col.className;
cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, '');
col.className = cls;
}
// adds sort indicators for current column being sorted
function addSortIndicators() {
getNthColumn(currentSort.index).className += currentSort.desc
? ' sorted-desc'
: ' sorted';
}
// adds event listeners for all sorter widgets
function enableUI() {
var i,
el,
ithSorter = function ithSorter(i) {
var col = cols[i];
return function() {
var desc = col.defaultDescSort;
if (currentSort.index === i) {
desc = !currentSort.desc;
}
sortByIndex(i, desc);
removeSortIndicators();
currentSort.index = i;
currentSort.desc = desc;
addSortIndicators();
};
};
for (i = 0; i < cols.length; i += 1) {
if (cols[i].sortable) {
// add the click event handler on the th so users
// dont have to click on those tiny arrows
el = getNthColumn(i).querySelector('.sorter').parentElement;
if (el.addEventListener) {
el.addEventListener('click', ithSorter(i));
} else {
el.attachEvent('onclick', ithSorter(i));
}
}
}
}
// adds sorting functionality to the UI
return function() {
if (!getTable()) {
return;
}
cols = loadColumns();
loadData();
addSearchBox();
addSortIndicators();
enableUI();
};
})();
window.addEventListener('load', addSorting);
@@ -0,0 +1,38 @@
TN:
SF:src/features/reference-feature/adapters/reference-http-gateway.ts
FN:37,mapProblem
FN:64,createReferenceHttpGateway
FN:68,list
FN:71,create
FN:74,get
FNF:5
FNH:5
FNDA:5,mapProblem
FNDA:8,createReferenceHttpGateway
FNDA:1,list
FNDA:6,create
FNDA:3,get
DA:25,2
DA:27,2
DA:38,5
DA:39,2
DA:44,3
DA:45,2
DA:50,1
DA:67,8
DA:69,1
DA:72,6
DA:75,3
LF:11
LH:11
BRDA:38,0,0,2
BRDA:38,0,1,3
BRDA:41,1,0,2
BRDA:41,1,1,1
BRDA:44,2,0,2
BRDA:44,2,1,1
BRDA:47,3,0,2
BRDA:47,3,1,1
BRF:8
BRH:8
end_of_record
+57 -1
View File
@@ -81,6 +81,55 @@
"script": "check:types:fixture:reference-operation", "script": "check:types:fixture:reference-operation",
"expect": "fail", "expect": "fail",
"expectedExitCode": 1, "expectedExitCode": 1,
"expectedDiagnosticId": "error TS2741:"
},
{
"id": "check-types-fixture-http-operation-input",
"script": "check:types:fixture:http-operation-input",
"expect": "fail",
"expectedExitCode": 1,
"expectedDiagnosticId": "error TS2353:"
},
{
"id": "check-types-fixture-http-wire-mapper",
"script": "check:types:fixture:http-wire-mapper",
"expect": "fail",
"expectedExitCode": 1,
"expectedDiagnosticId": "error TS2322:"
},
{
"id": "check-types-fixture-http-operation-id",
"script": "check:types:fixture:http-operation-id",
"expect": "fail",
"expectedExitCode": 1,
"expectedDiagnosticId": "error TS2345:"
},
{
"id": "check-types-fixture-http-route-id",
"script": "check:types:fixture:http-route-id",
"expect": "fail",
"expectedExitCode": 1,
"expectedDiagnosticId": "error TS2322:"
},
{
"id": "check-types-fixture-feature-contribution-input",
"script": "check:types:fixture:feature-contribution-input",
"expect": "fail",
"expectedExitCode": 1,
"expectedDiagnosticId": "error TS2322:"
},
{
"id": "check-types-fixture-feature-capability-selection",
"script": "check:types:fixture:feature-capability-selection",
"expect": "fail",
"expectedExitCode": 1,
"expectedDiagnosticId": "error TS2339:"
},
{
"id": "check-types-fixture-direct-feature-composition",
"script": "check:types:fixture:direct-feature-composition",
"expect": "fail",
"expectedExitCode": 1,
"expectedDiagnosticId": "error TS2345:" "expectedDiagnosticId": "error TS2345:"
}, },
{ {
@@ -95,7 +144,7 @@
"script": "check:types:fixture:route-runtime", "script": "check:types:fixture:route-runtime",
"expect": "fail", "expect": "fail",
"expectedExitCode": 1, "expectedExitCode": 1,
"expectedDiagnosticId": "error TS2353:" "expectedDiagnosticId": "error TS2740:"
}, },
{ {
"id": "check-types-fixture-page-action", "id": "check-types-fixture-page-action",
@@ -1698,6 +1747,13 @@
"check-types-fixture-feature-input", "check-types-fixture-feature-input",
"check-types-fixture-failure-kind", "check-types-fixture-failure-kind",
"check-types-fixture-reference-operation", "check-types-fixture-reference-operation",
"check-types-fixture-http-operation-input",
"check-types-fixture-http-wire-mapper",
"check-types-fixture-http-operation-id",
"check-types-fixture-http-route-id",
"check-types-fixture-feature-contribution-input",
"check-types-fixture-feature-capability-selection",
"check-types-fixture-direct-feature-composition",
"check-types-fixture-async-overlay", "check-types-fixture-async-overlay",
"check-types-fixture-route-runtime", "check-types-fixture-route-runtime",
"check-types-fixture-page-action", "check-types-fixture-page-action",
+117 -22
View File
@@ -12,97 +12,192 @@
{ {
"path": "src/adapters/http/bounded-body-reader.ts", "path": "src/adapters/http/bounded-body-reader.ts",
"owner": "http-runtime", "owner": "http-runtime",
"minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 90 } "minimum": {
"lines": 95,
"statements": 95,
"functions": 95,
"branches": 90
}
}, },
{ {
"path": "src/adapters/http/bounded-json.ts", "path": "src/adapters/http/bounded-json.ts",
"owner": "http-runtime", "owner": "http-runtime",
"minimum": { "lines": 85, "statements": 84, "functions": 95, "branches": 78 } "minimum": {
"lines": 85,
"statements": 84,
"functions": 95,
"branches": 78
}
}, },
{ {
"path": "src/adapters/http/http-execution-v3.ts", "path": "src/adapters/http/http-execution-v3.ts",
"owner": "http-runtime", "owner": "http-runtime",
"minimum": { "lines": 75, "statements": 73, "functions": 70, "branches": 52 } "minimum": {
"lines": 75,
"statements": 73,
"functions": 70,
"branches": 52
}
}, },
{ {
"path": "src/adapters/http/request-builder.ts", "path": "src/adapters/http/request-builder.ts",
"owner": "http-runtime", "owner": "http-runtime",
"minimum": { "lines": 85, "statements": 85, "functions": 95, "branches": 82 } "minimum": {
"lines": 85,
"statements": 85,
"functions": 95,
"branches": 82
}
}, },
{ {
"path": "src/adapters/http/retry-policy.ts", "path": "src/adapters/http/retry-policy.ts",
"owner": "http-runtime", "owner": "http-runtime",
"minimum": { "lines": 80, "statements": 78, "functions": 95, "branches": 78 } "minimum": {
"lines": 80,
"statements": 78,
"functions": 95,
"branches": 78
}
}, },
{ {
"path": "src/adapters/query-cache/server-state-scope-runtime.ts", "path": "src/adapters/query-cache/server-state-scope-runtime.ts",
"owner": "server-state-runtime", "owner": "server-state-runtime",
"minimum": { "lines": 85, "statements": 85, "functions": 85, "branches": 75 } "minimum": {
"lines": 85,
"statements": 85,
"functions": 85,
"branches": 75
}
}, },
{ {
"path": "src/adapters/service-worker/service-worker-lifecycle.ts", "path": "src/adapters/service-worker/service-worker-lifecycle.ts",
"owner": "service-worker-runtime", "owner": "service-worker-runtime",
"minimum": { "lines": 64, "statements": 60, "functions": 65, "branches": 43 } "minimum": {
"lines": 64,
"statements": 60,
"functions": 65,
"branches": 43
}
}, },
{ {
"path": "src/adapters/storage/browser-storage-adapter.ts", "path": "src/adapters/storage/browser-storage-adapter.ts",
"owner": "storage-runtime", "owner": "storage-runtime",
"minimum": { "lines": 60, "statements": 60, "functions": 70, "branches": 60 } "minimum": {
"lines": 60,
"statements": 60,
"functions": 70,
"branches": 60
}
}, },
{ {
"path": "src/adapters/telemetry/best-effort-telemetry.ts", "path": "src/adapters/telemetry/best-effort-telemetry.ts",
"owner": "telemetry-runtime", "owner": "telemetry-runtime",
"minimum": { "lines": 85, "statements": 85, "functions": 70, "branches": 75 } "minimum": {
"lines": 85,
"statements": 85,
"functions": 70,
"branches": 75
}
}, },
{ {
"path": "src/application/create-application.ts", "path": "src/application/create-application.ts",
"owner": "application-runtime", "owner": "application-runtime",
"minimum": { "lines": 90, "statements": 90, "functions": 80, "branches": 68 } "minimum": {
"lines": 90,
"statements": 90,
"functions": 80,
"branches": 68
}
}, },
{ {
"path": "src/application/policies/compatibility.ts", "path": "src/contracts/compatibility.ts",
"owner": "application-policy", "owner": "compatibility-contracts",
"minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 75 } "minimum": {
"lines": 95,
"statements": 95,
"functions": 95,
"branches": 75
}
}, },
{ {
"path": "src/application/policies/performance-budgets.ts", "path": "src/application/policies/performance-budgets.ts",
"owner": "application-policy", "owner": "application-policy",
"minimum": { "lines": 80, "statements": 80, "functions": 80, "branches": 40 } "minimum": {
"lines": 80,
"statements": 80,
"functions": 80,
"branches": 40
}
}, },
{ {
"path": "src/application/policies/promotion-readiness.ts", "path": "src/application/policies/promotion-readiness.ts",
"owner": "release-runtime", "owner": "release-runtime",
"minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 95 } "minimum": {
"lines": 95,
"statements": 95,
"functions": 95,
"branches": 95
}
}, },
{ {
"path": "src/application/use-cases/decide-chunk-recovery.ts", "path": "src/application/use-cases/decide-chunk-recovery.ts",
"owner": "application-runtime", "owner": "application-runtime",
"minimum": { "lines": 90, "statements": 90, "functions": 95, "branches": 85 } "minimum": {
"lines": 90,
"statements": 90,
"functions": 95,
"branches": 85
}
}, },
{ {
"path": "src/bootstrap/load-release-manifest.ts", "path": "src/bootstrap/load-release-manifest.ts",
"owner": "release-runtime", "owner": "release-runtime",
"minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 80 } "minimum": {
"lines": 90,
"statements": 90,
"functions": 90,
"branches": 80
}
}, },
{ {
"path": "src/bootstrap/read-bounded-boot-json.ts", "path": "src/bootstrap/read-bounded-boot-json.ts",
"owner": "bootstrap-runtime", "owner": "bootstrap-runtime",
"minimum": { "lines": 71, "statements": 66, "functions": 48, "branches": 57 } "minimum": {
"lines": 71,
"statements": 66,
"functions": 48,
"branches": 57
}
}, },
{ {
"path": "src/contracts/diagnostics.ts", "path": "src/contracts/diagnostics.ts",
"owner": "diagnostics-contracts", "owner": "diagnostics-contracts",
"minimum": { "lines": 68, "statements": 68, "functions": 95, "branches": 58 } "minimum": {
"lines": 68,
"statements": 68,
"functions": 95,
"branches": 58
}
}, },
{ {
"path": "src/features/reference-feature/adapters/reference-http-gateway.ts", "path": "src/features/reference-feature/adapters/reference-http-gateway.ts",
"owner": "reference-feature", "owner": "reference-feature",
"minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 90 } "minimum": {
"lines": 90,
"statements": 90,
"functions": 90,
"branches": 90
}
}, },
{ {
"path": "src/presentation/adapters/query/application-query.ts", "path": "src/presentation/adapters/query/application-query.ts",
"owner": "presentation-runtime", "owner": "presentation-runtime",
"minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 80 } "minimum": {
"lines": 90,
"statements": 90,
"functions": 90,
"branches": 80
}
} }
], ],
"highRiskPaths": [ "highRiskPaths": [
@@ -116,7 +211,7 @@
"src/adapters/storage/browser-storage-adapter.ts", "src/adapters/storage/browser-storage-adapter.ts",
"src/adapters/telemetry/best-effort-telemetry.ts", "src/adapters/telemetry/best-effort-telemetry.ts",
"src/application/create-application.ts", "src/application/create-application.ts",
"src/application/policies/compatibility.ts", "src/contracts/compatibility.ts",
"src/application/policies/performance-budgets.ts", "src/application/policies/performance-budgets.ts",
"src/application/policies/promotion-readiness.ts", "src/application/policies/promotion-readiness.ts",
"src/application/use-cases/decide-chunk-recovery.ts", "src/application/use-cases/decide-chunk-recovery.ts",
@@ -247,6 +247,8 @@ domain, application state, query cache, global store, diagnostics에 넣지 않
### 3.2 picker baseline과 enhancement ### 3.2 picker baseline과 enhancement
- 접근 가능한 `<input type="file">`가 모든 browser의 canonical baseline이다. - 접근 가능한 `<input type="file">`가 모든 browser의 canonical baseline이다.
baseline adapter는 `input.click()`으로 이 경로를 활성화한다. `showPicker()`
존재 여부는 portable baseline capability의 판정 기준으로 사용하지 않는다.
- `showOpenFilePicker()``showSaveFilePicker()`는 runtime method별 feature - `showOpenFilePicker()``showSaveFilePicker()`는 runtime method별 feature
detection을 거친 progressive enhancement다. UA sniffing을 사용하지 않는다. detection을 거친 progressive enhancement다. UA sniffing을 사용하지 않는다.
- picker 호출은 click/keyboard handler의 첫 browser action이어야 한다. 그 전에 - picker 호출은 click/keyboard handler의 첫 browser action이어야 한다. 그 전에
@@ -987,7 +989,7 @@ recipe의 공통 code는 UI/telemetry에 raw DOMException을 노출하지 않기
| `PERMISSION_DENIED` | picker/save permission | baseline/manual fallback | | `PERMISSION_DENIED` | picker/save permission | baseline/manual fallback |
| `LIMIT_EXCEEDED` | count/bytes/page/buffer budget | 입력 축소 | | `LIMIT_EXCEEDED` | count/bytes/page/buffer budget | 입력 축소 |
| `POLICY_REJECTED` | type/cache/data policy | 저장·전송 금지 | | `POLICY_REJECTED` | type/cache/data policy | 저장·전송 금지 |
| `BLOCKED` | IndexedDB older context | 다른 탭 close/retry UI | | `BLOCKED` | IndexedDB older context가 live open/upgrade를 막는 중 | 다른 탭 close/retry UI |
| `CONFLICT` | revision/generation/idempotency conflict | authoritative re-read | | `CONFLICT` | revision/generation/idempotency conflict | authoritative re-read |
| `MIGRATION_FAILED` | schema/data migration | read-only/online-only | | `MIGRATION_FAILED` | schema/data migration | read-only/online-only |
| `QUOTA_EXCEEDED` | actual write failure | rollback, reconstructable GC, bounded retry | | `QUOTA_EXCEEDED` | actual write failure | rollback, reconstructable GC, bounded retry |
@@ -999,6 +1001,12 @@ recipe의 공통 code는 UI/telemetry에 raw DOMException을 노출하지 않기
| `UNAVAILABLE` | browser/worker/storage temporarily unavailable | documented fallback | | `UNAVAILABLE` | browser/worker/storage temporarily unavailable | documented fallback |
| `UNSUPPORTED` | capability absence | baseline/online-only | | `UNSUPPORTED` | capability absence | baseline/online-only |
`IndexedDbConnectionStatus.BLOCKED`는 대기 중인 live attempt의 observable
상태다. blocked deadline이 끝나 `open()`이 `BLOCKED` failure로 settle되면
그 attempt는 더 이상 열리는 중이 아니므로 status는
`CLOSED / NOT_OPENED`로 전이한다. 늦게 성공한 native connection은 즉시 닫고
settled status를 되살리지 않는다.
user dismissal은 failure가 아니라 outcome이다. browser DOMException name은 user dismissal은 failure가 아니라 outcome이다. browser DOMException name은
adapter에서 이 vocabulary로 mapping하고 raw message/stack은 local bounded adapter에서 이 vocabulary로 mapping하고 raw message/stack은 local bounded
diagnostic에도 기본 저장하지 않는다. diagnostic에도 기본 저장하지 않는다.
@@ -1085,18 +1093,17 @@ Playwright Chromium, Firefox, WebKit에서 실제 secure-origin API를 검사한
`test:browser-capabilities`의 JUnit을 `test:browser-capabilities`의 JUnit을
`verify:browser-capability-evidence`가 읽어 세 engine의 testcase 집합 동일성, `verify:browser-capability-evidence`가 읽어 세 engine의 testcase 집합 동일성,
양수 실행 수, zero failure/error/skipped와 skipped/failure node 부재를 강제한다. 양수 실행 수, zero failure/error/skipped와 skipped/failure node 부재를 강제한다.
현재 checkout의 source suite는 engine마다 정확히 같은 14개 case(File 2, browser capability promotion은 source suite에서 관찰된 동일 testcase set을
IndexedDB 4, OPFS/Cache/StorageManager 각 1, cross-context invalidation 2, Chromium/Firefox/WebKit에서 모두 실행하는 규칙으로 관리한다. case 수를 문서
presigned streaming download/multipart upload/Image CDN 각 1)를 정의한다. 상수로 복제하지 않고 `verify:browser-capability-evidence`가 Chromium 결과를
promotion artifact는 Chromium/Firefox/WebKit의 14개씩, 총 42개가 모두 baseline set으로 계산해 Firefox/WebKit의 set 동일성, 양수 실행 수,
실행되어야 한다. 이 host의 failure/error/skipped 0과 failure/skipped node 부재를 검증한다.
WebKit은 필수 native libraries(예:
`libbacktrace.so.0`, `libevent-2.1.so.7`, `libjxl.so.0.8`, capability absence도 skip 사유가 아니다. 예를 들어 어떤 engine에서
`libavif.so.16`과 WPE 계열) 부재로 실행되지 않았고 현재 보존 artifact도 `navigator.storage`가 없다면 동일 testcase 안에서 adapter의
Chromium/Firefox 14개씩 총 28개만 통과한 상태다. 따라서 promotion evidence를 `UNSUPPORTED / STORAGE_ESTIMATE / ONLINE_ONLY` 결과를 browser truth로
충족하지 않으며 verifier가 실패하는 것이 정상이다. `INSTALLED` 전에는 필요한 검증한다. 세 engine을 실행할 수 없는 host의 artifact는 promotion evidence로
system dependency가 있는 CI/device에서 세 engine 전체 evidence를 새로 생성해야 사용하지 않는다.
한다.
- native input keyboard/focus/same-file reselection/multiple/dismissal - native input keyboard/focus/same-file reselection/multiple/dismissal
- Chromium conditional picker/save enhancement와 다른 engine fallback - Chromium conditional picker/save enhancement와 다른 engine fallback
@@ -48,18 +48,39 @@ its business requirement.
## Scenario B — IndexedDB local draft ## Scenario B — IndexedDB local draft
An executable consumer probe lives at: The original executable consumer probe remains at:
`tests/contract/consumer-experience/indexeddb-local-draft.test.ts` `tests/contract/consumer-experience/indexeddb-local-draft.test.ts`
with the feature-owned fixture: with its isolated fixture:
`tests/contract/consumer-experience/fixtures/local-draft-feature.ts` `tests/contract/consumer-experience/fixtures/local-draft-feature.ts`
The fixture models a small local-draft feature with save/find/remove and The architecture is now also exercised by a real vertical slice under:
optimistic revision checking.
Measured result: `src/features/local-draft-feature`
It owns the Local Draft domain/application API and binds an
`IndexedDbRepositoryPort<LocalDraft, never>` in its feature adapter. The
cross-capability composition contract lives at:
`tests/contract/reusable-capability/feature-adapter-composition.test.ts`
That test composes one HTTP-only contribution and the IndexedDB-only Local Draft
contribution through the same generic catalog path.
The browser-level composition proof lives at:
`tests/browser-capabilities/local-draft-composition.spec.ts`
It runs against native browser IndexedDB. The test creates the platform
`createIndexedDbRuntime`, exposes that runtime through the typed
`createIndexedDbRepositoryProvider`, composes
`LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION`, then executes Local Draft
save/find/remove through the feature API. The Local Draft feature still does
not import the IndexedDB runtime or native browser API.
Measured probe result:
| Metric | Result | | Metric | Result |
| --- | ---: | | --- | ---: |
@@ -81,8 +102,11 @@ The contract test rejects feature source that reaches for
## What this does and does not prove ## What this does and does not prove
The probe confirms that **feature business code does not need native IndexedDB The contract probe confirms that **feature business code does not need native
knowledge** once an `IndexedDbRepositoryPort` has been composed. IndexedDB knowledge** once an `IndexedDbRepositoryPort` has been composed.
The browser-capability proof additionally confirms that the same feature
contribution works when that port is backed by the repository's real
`createIndexedDbRuntime` and native IndexedDB implementation.
It does not prove that composition of `createIndexedDbRuntime` is cheap. It does not prove that composition of `createIndexedDbRuntime` is cheap.
That constructor still owns substantial infrastructure policy: That constructor still owns substantial infrastructure policy:
@@ -96,10 +120,15 @@ That constructor still owns substantial infrastructure policy:
- durability, scheduling and observation. - durability, scheduling and observation.
That complexity belongs at the composition/platform boundary, not in the That complexity belongs at the composition/platform boundary, not in the
feature. A new `create...Repository<T>` convenience factory should be added feature. The Local Draft vertical slice is now the second concrete consumer, and
only when a second real product consumer demonstrates which subset is stable it confirms that the stable seam is the typed repository provider plus a
enough to become a reusable profile. Creating one now would guess at policy and feature-owned repository identity. It does **not** show that dataset scope,
risk producing a universal storage abstraction. retention, migration, codec or lifecycle-authority configuration can be safely
collapsed into one universal `create...Repository<T>` factory.
A convenience profile should therefore be introduced only after another
IndexedDB-backed product feature repeats the same infrastructure policy, not
merely because two features consume the same repository port.
## Consumer-quality review checklist ## Consumer-quality review checklist
@@ -189,17 +189,18 @@ chunk별 `CapabilityResult<Uint8Array>`를 반환한다. backend upload example
실제 upload feature는 이 예시를 그대로 import하지 않고 purpose와 backend 실제 upload feature는 이 예시를 그대로 import하지 않고 purpose와 backend
protocol에 맞게 contract를 더 좁힌다. protocol에 맞게 contract를 더 좁힌다.
현재 checkout의 browser source suite는 engine마다 같은 14개 case(File 2, browser capability promotion은 source suite가 정의한 동일 testcase set을
IndexedDB 4, OPFS/Cache/StorageManager 각 1, cross-context invalidation 2, Chromium/Firefox/WebKit에서 모두 실행해야 한다. 구체적인 case 개수는 이 결정
presigned streaming download/multipart upload/Image CDN 각 1)를 정의한다. 문서에 고정하지 않는다. `verify:browser-capability-evidence`가 Chromium
promotion artifact는 Chromium/Firefox/WebKit 각각 14개, 총 42개를 모두 artifact에서 baseline set을 계산하고 Firefox/WebKit과의 set 동일성 및
실행해야 한다. WebKit은 현재 failure/error/skipped 0을 기계적으로 검증한다.
host의 필수 native libraries(예:
`libbacktrace.so.0`, `libevent-2.1.so.7`, `libjxl.so.0.8`, engine별 native capability 차이는 testcase를 skip하는 이유가 아니다. capability가
`libavif.so.16`과 WPE 계열) 부재로 실행되지 않았다. 보존 artifact는 없으면 adapter의 명시적 unsupported/degraded result를 같은 testcase에서
Chromium/Firefox 14개씩 총 28개만 통과했으므로 검증한다. baseline `<input type="file">` activation은 portable한
`verify:browser-capability-evidence`가 실패하는 것이 정상이다. 세 engine `input.click()` 경로를 사용하고, `showOpenFilePicker()` 같은 API는 별도
evidence가 완성되기 전에는 product 상태를 `INSTALLED`로 올리지 않는다. progressive enhancement로 유지한다. 세 engine evidence가 완성되기 전에는
product 상태를 `INSTALLED`로 올리지 않는다.
## 선택 이후 필요한 구현 ## 선택 이후 필요한 구현
@@ -105,6 +105,39 @@ Central installed catalogs are aggregation points only:
Feature-specific composition belongs under the feature itself. Central Feature-specific composition belongs under the feature itself. Central
catalogs must not grow feature-specific branching logic. catalogs must not grow feature-specific branching logic.
Adapter contributions declare the platform capabilities they consume through
`needs`. The generic contribution seam in
`src/features/feature-adapter-contribution.ts` derives the context from that
list, so an HTTP-only feature cannot reach IndexedDB and an IndexedDB-only
feature does not receive the HTTP executor. It also binds
`featureId -> ApplicationFeatureInputs[featureId]` at the contribution
definition site instead of recovering that relationship with a final catalog
cast.
A contribution becomes composable only through
`defineFeatureAdapterContribution()`. That factory adds the private
contribution brand required by `composeFeatureAdapterInputs()`; a raw object
with the same visible fields is rejected by TypeScript and checked again at
runtime. The negative type fixture
`invalid-direct-feature-composition.ts` is part of FE-GATE-003 so this
authority cannot be bypassed by calling the composer directly.
For IndexedDB-backed features, `createIndexedDbRepositoryProvider()` is the
composition helper that maps feature-owned repository identities to typed
`IndexedDbRepositoryPort` instances. It does not create a universal storage
repository or move dataset/migration/lifecycle policy into the feature.
The repository now has two executable contribution shapes:
- Reference feature: `needs: ["http"]`
- Local Draft feature: `needs: ["indexedDb"]`
Local Draft is a compiled architecture consumer used to prove the second
capability shape; it is not added to the default product manifest. Its
browser-capability test composes the feature over the real
`createIndexedDbRuntime` and native IndexedDB, proving that this seam is not
limited to an in-memory contract fixture.
## Presentation consumer surface ## Presentation consumer surface
`ApplicationProvider` remains the composition root for presentation, but new `ApplicationProvider` remains the composition root for presentation, but new
@@ -337,7 +337,14 @@ Run on the landed tree. Only what actually passed is claimed as passing.
| --- | --- | --- | | --- | --- | --- |
| `tests/unit/ci-workflow-generation.test.ts` | 82 failed / 325 passed | Identical on the pre-change baseline (`git stash` comparison). The subprocess gates it spawns cannot run in this sandbox. | | `tests/unit/ci-workflow-generation.test.ts` | 82 failed / 325 passed | Identical on the pre-change baseline (`git stash` comparison). The subprocess gates it spawns cannot run in this sandbox. |
| `tests/unit/ci-artifact-contract.test.ts` | fails | Unchanged pre-existing sandbox, cgroup and `/tmp` permission behaviour. | | `tests/unit/ci-artifact-contract.test.ts` | fails | Unchanged pre-existing sandbox, cgroup and `/tmp` permission behaviour. |
| `tests/unit/security-followup.test.ts`, `tests/unit/provider-guardian-transaction.test.ts`, `tests/unit/risk-coverage.test.ts` | flaky under full-suite load | All three pass in a fresh process (78 passed together). They spawn and reap process groups, so their timing assertions are load sensitive. | | `tests/unit/security-followup.test.ts`, `tests/unit/provider-guardian-transaction.test.ts`, `tests/unit/risk-coverage.test.ts` | flaky under full-suite load | Historical baseline result. All three passed in a fresh process (78 passed together). |
> Current classification update (2026-09-18): the provider guardian transaction
> suite moved to `tests/integration/provider-guardian-transaction.test.ts`.
> It spawns child processes and exercises filesystem/IPC/process lifecycle, so it
> is not part of the pure unit pool. Its READY/PUBLISHED wait uses a dedicated
> test watchdog rather than treating a 1-second scheduler delay as a production
> protocol deadline.
### Destructive fixture hazard — fixed ### Destructive fixture hazard — fixed
@@ -375,7 +382,7 @@ files above plus the two flaky-under-load ones:
| `tests/unit/ci-workflow-generation.test.ts` | 82 | Identical on the pre-change baseline (`git stash` comparison). Its subprocess gates cannot run in this sandbox. | | `tests/unit/ci-workflow-generation.test.ts` | 82 | Identical on the pre-change baseline (`git stash` comparison). Its subprocess gates cannot run in this sandbox. |
| `tests/unit/ci-artifact-contract.test.ts` | 19 | Unchanged pre-existing sandbox, cgroup and `/tmp` permission behaviour. | | `tests/unit/ci-artifact-contract.test.ts` | 19 | Unchanged pre-existing sandbox, cgroup and `/tmp` permission behaviour. |
| `tests/unit/security-followup.test.ts` | 2 | Passes in isolation. | | `tests/unit/security-followup.test.ts` | 2 | Passes in isolation. |
| `tests/unit/provider-guardian-transaction.test.ts` | 1 | Passes in isolation. | | `tests/unit/provider-guardian-transaction.test.ts` (historical path; now `tests/integration/provider-guardian-transaction.test.ts`) | 1 | Historical baseline: passed in isolation; current suite is classified as process integration. |
1619 passed / 1723 total, and `tests/unit/removal-fixture.test.ts`, 1619 passed / 1723 total, and `tests/unit/removal-fixture.test.ts`,
`tests/unit/supply-chain.test.ts` and `tests/unit/supply-chain.test.ts` and
@@ -148,6 +148,9 @@ read-only나 online-only가 사용자 작성 내용을 잃게 한다면 먼저 e
### 신호 ### 신호
- `BLOCKED`, `UPGRADE_BLOCKED` 또는 blocked duration bucket 증가 - `BLOCKED`, `UPGRADE_BLOCKED` 또는 blocked duration bucket 증가
- live attempt가 기다리는 동안 status는 `BLOCKED`
- blocked deadline settle 뒤에는 open result가 `BLOCKED` failure여도
runtime status는 `CLOSED / NOT_OPENED`
- `versionchange` 뒤 connection이 남음 - `versionchange` 뒤 connection이 남음
- repeated reload/update loop - repeated reload/update loop
- open/maintenance의 `POLICY_REJECTED`: immutable dataset binding missing/mismatch - open/maintenance의 `POLICY_REJECTED`: immutable dataset binding missing/mismatch
@@ -500,16 +503,13 @@ browser engine/version, fixture ID, fault phase, bounded counts/buckets와 PASS/
promotion 직전에는 다음 repository evidence도 함께 보존한다. promotion 직전에는 다음 repository evidence도 함께 보존한다.
- `test:browser-capabilities`가 만든 JUnit에서 Chromium, Firefox, WebKit이 동일 - `test:browser-capabilities`가 만든 JUnit에서 Chromium, Firefox, WebKit이
14개 testcase set(File 2, IndexedDB 4, OPFS/Cache/StorageManager 각 1, source suite의 동일 testcase set을 실제 실행하고 failure/error/skipped가 모두
cross-context invalidation 2, presigned streaming download/multipart 0이어야 한다. 구체적인 case 수는 이 runbook에 복제하지 않는다.
upload/Image CDN 각 1)을 실제 실행해 총 42개이며 failure/error/skipped가 모두 `verify:browser-capability-evidence`가 Chromium을 baseline으로 testcase set과
0이어야 한다. engine 집합을 동적으로 검증한다. native capability가 없는 engine도 testcase를
`verify:browser-capability-evidence`가 engine 집합과 testcase 동일성을 skip하지 않고 adapter의 명시적 `UNSUPPORTED`/degraded 결과를 검증해야 한다.
기계적으로 검증한다. 현재 artifact는 Chromium/Firefox 14개씩 총 28개 세 engine 중 하나라도 실행되지 않은 artifact는 promotion 가능 상태
통과했지만 WebKit 실행에 필요한 native libraries(예:
`libbacktrace.so.0`, `libevent-2.1.so.7`, `libjxl.so.0.8`,
`libavif.so.16`과 WPE 계열)가 이 host에 없으므로 아직 promotion 가능 상태가
아니다. 아니다.
- `artifacts/quality/vite-module-inventory.json`에서 optional runtime source root가 - `artifacts/quality/vite-module-inventory.json`에서 optional runtime source root가
production chunk에 없음을 `check:optional-recipes`로 검증한다. production chunk에 없음을 `check:optional-recipes`로 검증한다.
@@ -105,6 +105,24 @@ component suite로 분리되어 있다.
실제 QueryClient 위에서 검증한다. HTTP 자동 retry가 소유자이므로 이 adapter의 실제 QueryClient 위에서 검증한다. HTTP 자동 retry가 소유자이므로 이 adapter의
query/mutation vendor retry는 꺼져 있다. query/mutation vendor retry는 꺼져 있다.
같은 기준을 대형 unit suite에도 적용한다. 줄 수를 기준으로 자르지 않고 실패가
가리켜야 하는 behavior owner를 기준으로 분리한다.
- `public-response-cache.test.ts`: 일반 stage/activate/lookup/cache contract
- `public-response-cache-repair.test.ts`: active release repair의 failure atomicity
- `resumable-upload-runtime.test.ts`: upload/reconcile/control-plane 흐름
- `resumable-upload-runtime-teardown.test.ts`: bounded drain/raw provider teardown
- `security-followup.test.ts`: archived local evidence
- `security-provider-evidence.test.ts`: provider signature/supervision/process lifecycle
- `security-promotion-staging.test.ts`: private staging seal/replay/CLI identity
- `tests/integration/provider-guardian-transaction.test.ts`: child process,
filesystem, IPC frame, READY/PUBLISHED handshake와 process lifecycle. pure unit
pool에 두지 않으며 handshake budget은 protocol timeout이 아닌 test watchdog이다.
-`*-fixture.ts`: 해당 owner들 사이에서만 공유하는 deterministic test fixture
이 분리는 production owner와 test failure surface를 맞추기 위한 것이며, 단순
LOC 감축 목적이 아니다.
#### Form과 route 위험 #### Form과 route 위험
form component/reference feature test가 error summary, 첫 오류 focus, Zod form component/reference feature test가 error summary, 첫 오류 focus, Zod
+6 -2
View File
@@ -11,7 +11,7 @@ Each gate is blocking in its declared scope. Failures are not downgraded with
| unit | `pnpm test:unit` | Node-only domain/application/pure policy/runtime units; no systemd/bwrap/cgroup prerequisite | `artifacts/tests/unit.xml` | | unit | `pnpm test:unit` | Node-only domain/application/pure policy/runtime units; no systemd/bwrap/cgroup prerequisite | `artifacts/tests/unit.xml` |
| capability contract | `pnpm test:contract` | reusable capability consumer contracts | `artifacts/tests/contract.xml` | | capability contract | `pnpm test:contract` | reusable capability consumer contracts | `artifacts/tests/contract.xml` |
| component | `pnpm test:component` | React/hook/UI behavior | `artifacts/tests/component.xml` | | component | `pnpm test:component` | React/hook/UI behavior | `artifacts/tests/component.xml` |
| integration | `pnpm test:integration` | HTTP/MSW, IndexedDB and composed browser-runtime boundaries | `artifacts/tests/integration.xml` | | integration | `pnpm test:integration` | HTTP/MSW, IndexedDB, composed browser-runtime boundaries, child-process/filesystem/IPC integration | `artifacts/tests/integration.xml` |
| system / CI runner | `pnpm test:system` | compatible Linux host with systemd, bubblewrap, cgroup v2 and CI-provider process controls | `artifacts/tests/system.xml` | | system / CI runner | `pnpm test:system` | compatible Linux host with systemd, bubblewrap, cgroup v2 and CI-provider process controls | `artifacts/tests/system.xml` |
| end-to-end | `pnpm test:e2e` | pinned browser engines | `artifacts/tests/e2e/` | | end-to-end | `pnpm test:e2e` | pinned browser engines | `artifacts/tests/e2e/` |
| accessibility | `pnpm test:a11y` | pinned browser engines | `artifacts/tests/a11y.json` | | accessibility | `pnpm test:a11y` | pinned browser engines | `artifacts/tests/a11y.json` |
@@ -64,7 +64,11 @@ CI / release assurance:
A host-level process/sandbox test must not be placed in `tests/unit` merely A host-level process/sandbox test must not be placed in `tests/unit` merely
because it uses Vitest. The classification follows the system boundary and because it uses Vitest. The classification follows the system boundary and
prerequisites, not the test framework. prerequisites, not the test framework. Process-heavy tests that spawn child
processes but do not require privileged host facilities belong in
`tests/integration`; for example the provider guardian transaction protocol
lives at `tests/integration/provider-guardian-transaction.test.ts`. Its
handshake timeout is a test watchdog, not a production protocol deadline.
End-to-end and automated accessibility scenarios run on the pinned Chromium, End-to-end and automated accessibility scenarios run on the pinned Chromium,
Firefox, and WebKit engines. The responsive contract explicitly exercises Firefox, and WebKit engines. The responsive contract explicitly exercises
+24 -17
View File
@@ -30,22 +30,22 @@
"check:types:node": "tsc --project tsconfig.node.json", "check:types:node": "tsc --project tsconfig.node.json",
"check:types:test": "tsc --project tsconfig.test.json", "check:types:test": "tsc --project tsconfig.test.json",
"check:types:recipes": "node scripts/check-optional-recipe-types.ts", "check:types:recipes": "node scripts/check-optional-recipe-types.ts",
"check:types:fixture": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext tests/fixtures/typecheck/invalid-port-call.ts", "check:types:fixture": "tsc -p tests/fixtures/typecheck/tsconfig.port-call.json",
"check:types:fixture:ts-port": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-port-implementation.ts", "check:types:fixture:ts-port": "tsc -p tests/fixtures/typecheck/tsconfig.port-implementation.json",
"check:types:fixture:ts-result": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-result-narrowing.ts", "check:types:fixture:ts-result": "tsc -p tests/fixtures/typecheck/tsconfig.result-narrowing.json",
"check:types:fixture:application-output": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-output.ts", "check:types:fixture:application-output": "tsc -p tests/fixtures/typecheck/tsconfig.application-output.json",
"check:types:fixture:application-input": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-input.ts", "check:types:fixture:application-input": "tsc -p tests/fixtures/typecheck/tsconfig.application-input.json",
"check:types:fixture:feature-input": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-feature-input.ts", "check:types:fixture:feature-input": "tsc -p tests/fixtures/typecheck/tsconfig.feature-input.json",
"check:types:fixture:failure-kind": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-failure-kind.ts", "check:types:fixture:failure-kind": "tsc -p tests/fixtures/typecheck/tsconfig.failure-kind.json",
"check:types:fixture:reference-operation": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-reference-operation.ts", "check:types:fixture:reference-operation": "tsc -p tests/fixtures/typecheck/tsconfig.reference-operation.json",
"check:types:fixture:async-overlay": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-async-overlay.ts", "check:types:fixture:async-overlay": "tsc -p tests/fixtures/typecheck/tsconfig.async-overlay.json",
"check:types:fixture:route-runtime": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-route-runtime.ts", "check:types:fixture:route-runtime": "tsc -p tests/fixtures/typecheck/tsconfig.route-runtime.json",
"check:types:fixture:page-action": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-page-action.tsx", "check:types:fixture:page-action": "tsc -p tests/fixtures/typecheck/tsconfig.page-action.json",
"check:types:fixture:icon-button": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-icon-button.tsx", "check:types:fixture:icon-button": "tsc -p tests/fixtures/typecheck/tsconfig.icon-button.json",
"check:types:fixture:i18n-key": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-key.ts", "check:types:fixture:i18n-key": "tsc -p tests/fixtures/typecheck/tsconfig.message-key.json",
"check:types:fixture:i18n-params": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-params.ts", "check:types:fixture:i18n-params": "tsc -p tests/fixtures/typecheck/tsconfig.message-params.json",
"check:types:fixture:diagnostics": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-diagnostics-port.ts", "check:types:fixture:diagnostics": "tsc -p tests/fixtures/typecheck/tsconfig.diagnostics-port.json",
"check:types:fixture:image-resolve-signal": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-image-cdn-resolve-signal.ts", "check:types:fixture:image-resolve-signal": "tsc -p tests/fixtures/typecheck/tsconfig.image-cdn-resolve-signal.json",
"test:runtime-schema": "node scripts/run-vitest.ts run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests", "test:runtime-schema": "node scripts/run-vitest.ts run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests",
"test:unit": "node scripts/run-vitest.ts run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml", "test:unit": "node scripts/run-vitest.ts run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml",
"test:contract": "node scripts/run-vitest.ts run tests/contract --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/contract.xml", "test:contract": "node scripts/run-vitest.ts run tests/contract --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/contract.xml",
@@ -124,7 +124,14 @@
"build:app-only": "vite build && node scripts/generate-build-manifest.ts", "build:app-only": "vite build && node scripts/generate-build-manifest.ts",
"generate:contract-set": "node scripts/generate-contract-set.ts", "generate:contract-set": "node scripts/generate-contract-set.ts",
"check:types:web-worker": "tsc --project tsconfig.web-worker.json", "check:types:web-worker": "tsc --project tsconfig.web-worker.json",
"check:types:service-worker": "tsc --project tsconfig.service-worker.json" "check:types:service-worker": "tsc --project tsconfig.service-worker.json",
"check:types:fixture:http-operation-input": "tsc -p tests/fixtures/typecheck/tsconfig.http-operation-input.json",
"check:types:fixture:http-wire-mapper": "tsc -p tests/fixtures/typecheck/tsconfig.http-wire-mapper.json",
"check:types:fixture:http-operation-id": "tsc -p tests/fixtures/typecheck/tsconfig.http-operation-id.json",
"check:types:fixture:http-route-id": "tsc -p tests/fixtures/typecheck/tsconfig.http-route-id.json",
"check:types:fixture:feature-contribution-input": "tsc -p tests/fixtures/typecheck/tsconfig.feature-contribution-input.json",
"check:types:fixture:feature-capability-selection": "tsc -p tests/fixtures/typecheck/tsconfig.feature-capability-selection.json",
"check:types:fixture:direct-feature-composition": "tsc -p tests/fixtures/typecheck/tsconfig.direct-feature-composition.json"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "5.101.4", "@tanstack/react-query": "5.101.4",
+3 -3
View File
@@ -443,7 +443,7 @@ export type LoadCiGateContractOptions = Readonly<{
}>; }>;
const CANONICAL_GATE_SHAPE_SHA256 = const CANONICAL_GATE_SHAPE_SHA256 =
"372959f50b9c5a228bdc85dce33ca7968c72414ce3b6d5534c9e6eaf554f6ce4"; "43dc4ed1cdaf21a674bd069d42eb2c32276afd8f763d72e52c9f7c3adccbeb58";
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string { function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
const normalized = gates.map( const normalized = gates.map(
@@ -477,9 +477,9 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
if (contract.gates.length !== 27) { if (contract.gates.length !== 27) {
failures.push(`gate authority baseline must contain exactly 27 gates; received ${contract.gates.length}`); failures.push(`gate authority baseline must contain exactly 27 gates; received ${contract.gates.length}`);
} }
if (contract.commands.length !== 84 || commandReferenceCount !== 96) { if (contract.commands.length !== 91 || commandReferenceCount !== 103) {
failures.push( failures.push(
`command authority baseline must contain exactly 84 definitions and 96 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`, `command authority baseline must contain exactly 91 definitions and 103 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`,
); );
} }
if (contract.artifacts.length !== 109) { if (contract.artifacts.length !== 109) {
+109 -12
View File
@@ -42,18 +42,39 @@ const featureOwnedPaths = [
featureSource, featureSource,
featureTests, featureTests,
"tests/integration/http-scenario-catalog.test.ts", "tests/integration/http-scenario-catalog.test.ts",
"tests/component/product-feature-switch.test.tsx",
"tests/e2e/reference-form.spec.ts", "tests/e2e/reference-form.spec.ts",
"tests/e2e/reference-route.spec.ts", "tests/e2e/reference-route.spec.ts",
"tests/mocks", "tests/mocks",
"tests/fixtures/typecheck/invalid-feature-input.ts", "tests/fixtures/typecheck/invalid-feature-input.ts",
"tests/fixtures/typecheck/invalid-reference-operation.ts", "tests/fixtures/typecheck/invalid-reference-operation.ts",
"tests/fixtures/typecheck/invalid-http-operation-input.ts",
"tests/fixtures/typecheck/invalid-http-wire-mapper.ts",
"tests/fixtures/typecheck/invalid-http-operation-id.ts",
"tests/fixtures/typecheck/invalid-http-route-id.ts",
"tests/fixtures/typecheck/invalid-feature-contribution-input.ts",
"tests/fixtures/typecheck/invalid-feature-capability-selection.ts",
"tests/fixtures/typecheck/tsconfig.feature-input.json",
"tests/fixtures/typecheck/tsconfig.reference-operation.json",
"tests/fixtures/typecheck/tsconfig.http-operation-input.json",
"tests/fixtures/typecheck/tsconfig.http-wire-mapper.json",
"tests/fixtures/typecheck/tsconfig.http-operation-id.json",
"tests/fixtures/typecheck/tsconfig.http-route-id.json",
"tests/fixtures/typecheck/tsconfig.feature-contribution-input.json",
"tests/fixtures/typecheck/tsconfig.feature-capability-selection.json",
]; ];
const emptyProductManifest = `export const COMPILED_PRODUCT_FEATURE_IDS: readonly string[] = Object.freeze([]);
export const INSTALLED_PRODUCT_FEATURES: readonly never[] = Object.freeze([]);
export const INSTALLED_PRODUCT_FEATURE_IDS: readonly string[] = Object.freeze([]);
`;
const emptyContracts = `import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.ts"; const emptyContracts = `import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.ts";
import { PLATFORM_ROUTE_REGISTRY, type RouteDefinition } from "../contracts/routes.ts"; import { PLATFORM_ROUTE_REGISTRY, type RouteDefinition } from "../contracts/routes.ts";
import { PLATFORM_SCHEMA_REGISTRY } from "../contracts/schema-registry.ts"; import { PLATFORM_SCHEMA_REGISTRY } from "../contracts/schema-registry.ts";
import { INSTALLED_PRODUCT_FEATURES } from "./installed-product-manifest.ts";
export const INSTALLED_FEATURE_CONTRACTS: readonly unknown[] = Object.freeze([]); export const INSTALLED_FEATURE_CONTRACTS = INSTALLED_PRODUCT_FEATURES;
export const ROUTE_REGISTRY = PLATFORM_ROUTE_REGISTRY; export const ROUTE_REGISTRY = PLATFORM_ROUTE_REGISTRY;
export const ROUTE_RUNTIME_CONTRACT = PLATFORM_ROUTE_RUNTIME_CONTRACT; export const ROUTE_RUNTIME_CONTRACT = PLATFORM_ROUTE_RUNTIME_CONTRACT;
export const API_OPERATIONS = Object.freeze({}); export const API_OPERATIONS = Object.freeze({});
@@ -64,6 +85,7 @@ export const INVALIDATION_REGISTRY = Object.freeze({
}); });
export const INVALIDATION_TOPIC_VERSIONS = Object.freeze([]); export const INVALIDATION_TOPIC_VERSIONS = Object.freeze([]);
export const SCHEMA_REGISTRY = PLATFORM_SCHEMA_REGISTRY; export const SCHEMA_REGISTRY = PLATFORM_SCHEMA_REGISTRY;
export const ROUTE_FEATURE_OWNER: Readonly<Record<string, string>> = Object.freeze({});
export const NAVIGATION_ROUTES = Object.freeze( export const NAVIGATION_ROUTES = Object.freeze(
Object.values(ROUTE_REGISTRY) Object.values(ROUTE_REGISTRY)
.filter((definition) => definition.navigationOrder !== null) .filter((definition) => definition.navigationOrder !== null)
@@ -85,14 +107,39 @@ export function routePath(routeId: string): string {
const emptyRuntimes = `import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.ts"; const emptyRuntimes = `import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.ts";
import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.tsx"; import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.tsx";
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
const COMPILED_FEATURE_RUNTIME_CONTRIBUTIONS: readonly Readonly<{
featureId: string;
}>[] = Object.freeze([]);
const INSTALLED_FEATURE_RUNTIME_CONTRIBUTIONS =
COMPILED_FEATURE_RUNTIME_CONTRIBUTIONS.filter((contribution) =>
INSTALLED_PRODUCT_FEATURE_IDS.includes(contribution.featureId),
);
void INSTALLED_FEATURE_RUNTIME_CONTRIBUTIONS;
export const ROUTE_CODECS = PLATFORM_ROUTE_CODECS; export const ROUTE_CODECS = PLATFORM_ROUTE_CODECS;
export const ROUTE_RUNTIME = PLATFORM_ROUTE_RUNTIME; export const ROUTE_RUNTIME = PLATFORM_ROUTE_RUNTIME;
`; `;
const emptyAdapters = `export function createInstalledFeatureInputs(_context: unknown) { const emptyAdapters = `import {
void _context; composeFeatureAdapterInputs,
return Object.freeze({}); type InstalledFeatureInputs,
} from "./feature-adapter-contribution.ts";
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
const COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS = Object.freeze([] as const);
export function createInstalledFeatureInputs(
context: Readonly<Record<never, never>>,
): InstalledFeatureInputs {
return composeFeatureAdapterInputs(
COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS,
INSTALLED_PRODUCT_FEATURE_IDS,
context,
);
} }
`; `;
@@ -101,9 +148,17 @@ const emptyContractContributions = `import {
type InstalledContractContribution, type InstalledContractContribution,
type InstalledContractPackageIdentity, type InstalledContractPackageIdentity,
} from "../contracts/external-contract-runtime.ts"; } from "../contracts/external-contract-runtime.ts";
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
const COMPILED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
Object.freeze([]);
export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] = export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
Object.freeze([]); Object.freeze(
COMPILED_CONTRACT_CONTRIBUTIONS.filter((contribution) =>
INSTALLED_PRODUCT_FEATURE_IDS.includes(contribution.featureId),
),
);
export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions( export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions(
INSTALLED_CONTRACT_CONTRIBUTIONS, INSTALLED_CONTRACT_CONTRIBUTIONS,
@@ -181,6 +236,10 @@ try {
force: true, force: true,
}); });
} }
await writeFile(
path.join(fixtureRoot, "src/features/installed-product-manifest.ts"),
emptyProductManifest,
);
await writeFile( await writeFile(
path.join(fixtureRoot, "src/features/installed-feature-contracts.ts"), path.join(fixtureRoot, "src/features/installed-feature-contracts.ts"),
emptyContracts, emptyContracts,
@@ -202,6 +261,34 @@ try {
emptyContractContributions, emptyContractContributions,
); );
// Runtime profiles are product configuration. Once the feature is physically
// removed, its override key must disappear too or the built config still
// advertises a source capability that no longer exists.
for (const profile of [
"local",
"development",
"staging",
"production",
] as const) {
const profilePath = path.join(
fixtureRoot,
"config/runtime",
`${profile}.json`,
);
const runtimeConfig = JSON.parse(
await readFile(profilePath, "utf8"),
) as {
FEATURE_OVERRIDES?: Record<string, string>;
};
if (runtimeConfig.FEATURE_OVERRIDES) {
delete runtimeConfig.FEATURE_OVERRIDES["reference-feature"];
}
await writeFile(
profilePath,
`${JSON.stringify(runtimeConfig, null, 2)}\n`,
);
}
const retainedCriticalModules = coveragePolicy.criticalModules.filter( const retainedCriticalModules = coveragePolicy.criticalModules.filter(
(modulePolicy) => !modulePolicy.path?.startsWith(`${featureSource}/`), (modulePolicy) => !modulePolicy.path?.startsWith(`${featureSource}/`),
); );
@@ -296,6 +383,12 @@ try {
const removedCiScripts = new Set([ const removedCiScripts = new Set([
"check:types:fixture:feature-input", "check:types:fixture:feature-input",
"check:types:fixture:reference-operation", "check:types:fixture:reference-operation",
"check:types:fixture:http-operation-input",
"check:types:fixture:http-wire-mapper",
"check:types:fixture:http-operation-id",
"check:types:fixture:http-route-id",
"check:types:fixture:feature-contribution-input",
"check:types:fixture:feature-capability-selection",
"test:http-scenario-evidence", "test:http-scenario-evidence",
"test:reference-feature", "test:reference-feature",
]); ]);
@@ -370,13 +463,17 @@ try {
["build", runPnpm("build")], ["build", runPnpm("build")],
]; ];
const builtResidue: string[] = []; const builtResidue: string[] = [];
for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) { const buildSucceeded =
if (!/\.(?:js|css|html|json)$/.test(file)) continue; checks.find(([name]) => name === "build")?.[1] === true;
const content = await readFile(file, "utf8"); if (buildSucceeded) {
if ( for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) {
/REFERENCE_RESOURCE|reference-feature|reference-resource/i.test(content) if (!/\.(?:js|css|html|json)$/.test(file)) continue;
) { const content = await readFile(file, "utf8");
builtResidue.push(path.relative(fixtureRoot, file)); if (
/REFERENCE_RESOURCE|reference-feature|reference-resource/i.test(content)
) {
builtResidue.push(path.relative(fixtureRoot, file));
}
} }
} }
const routeCatalog = await import( const routeCatalog = await import(
@@ -108,14 +108,12 @@ export class NativeInputFilePicker implements DisposableFilePicker {
const addInputEvent = options.input.addEventListener; const addInputEvent = options.input.addEventListener;
const removeInputEvent = options.input.removeEventListener; const removeInputEvent = options.input.removeEventListener;
const getInputAttribute = options.input.getAttribute; const getInputAttribute = options.input.getAttribute;
const showPicker = options.input.showPicker;
const click = options.input.click; const click = options.input.click;
if ( if (
typeof addInputEvent !== "function" || typeof addInputEvent !== "function" ||
typeof removeInputEvent !== "function" || typeof removeInputEvent !== "function" ||
typeof getInputAttribute !== "function" || typeof getInputAttribute !== "function" ||
(typeof showPicker !== "function" && typeof click !== "function"
typeof click !== "function")
) { ) {
throw new TypeError("Native file input API is invalid."); throw new TypeError("Native file input API is invalid.");
} }
@@ -123,10 +121,11 @@ export class NativeInputFilePicker implements DisposableFilePicker {
add: addInputEvent.bind(options.input), add: addInputEvent.bind(options.input),
remove: removeInputEvent.bind(options.input), remove: removeInputEvent.bind(options.input),
getAttribute: getInputAttribute.bind(options.input), getAttribute: getInputAttribute.bind(options.input),
activate: // The baseline <input type="file"> path intentionally uses click().
typeof showPicker === "function" // showPicker() exists in multiple engines but is not a portable
? showPicker.bind(options.input) // automation/event-interception contract. Enhanced native picking is a
: click.bind(options.input), // separate capability owned by EnhancedFilePicker.
activate: click.bind(options.input),
}); });
const windowHost = options.window ?? globalThis.window; const windowHost = options.window ?? globalThis.window;
if (windowHost) { if (windowHost) {
+217 -54
View File
@@ -1,7 +1,8 @@
import type { Result } from "../../contracts/result.ts";
import type { import type {
MappingResult, InstalledHttpContract,
} from "../../contracts/boundary-mapper.ts"; } from "../../contracts/external-contract-runtime.ts";
import type { Result } from "../../contracts/result.ts";
import type { MappingResult } from "../../contracts/boundary-mapper.ts";
import { import {
createFailure, createFailure,
kindForStatus, kindForStatus,
@@ -10,55 +11,199 @@ import {
type FailureKind, type FailureKind,
} from "../../contracts/errors.ts"; } from "../../contracts/errors.ts";
import type { MutationIntent } from "../../contracts/mutation-intent.ts"; import type { MutationIntent } from "../../contracts/mutation-intent.ts";
import type { HttpExecutionOutcome } from "./http-execution-v3.ts"; import type {
HttpExecutionOutcome,
SafeResponseMetadata,
} from "./http-execution-v3.ts";
type ValidatorValue<Validator> =
Validator extends Readonly<{
safeParse(value: unknown): infer ParseResult;
}>
? Extract<
ParseResult,
Readonly<{ success: true; data: unknown }>
> extends Readonly<{ data: infer Value }>
? Value
: never
: never;
export type HttpContractInput<Contract> =
Contract extends Readonly<{
contract: Readonly<{ inputValidator: infer Validator }>;
}>
? ValidatorValue<Validator>
: never;
export type HttpContractWireOutput<Contract> =
Contract extends Readonly<{
contract: Readonly<{ outputValidator: infer Validator }>;
}>
? ValidatorValue<Validator>
: never;
export type HttpContractProblem<Contract> =
Contract extends Readonly<{
contract: Readonly<{ problemValidator: infer Validator }>;
}>
? ValidatorValue<Validator>
: never;
export type HttpContractOperationId<Contract> =
Contract extends Readonly<{
contract: Readonly<{ operationId: infer OperationId extends string }>;
}>
? OperationId
: never;
export type InstalledHttpOperationExecutor = Readonly<{ export type InstalledHttpOperationExecutor = Readonly<{
execute( execute<Input, WireOutput, Problem>(
operationId: string, contract: InstalledHttpContract<Input, WireOutput, Problem>,
input: unknown, input: Input,
context: Readonly<{ context: Readonly<{
routeId: string; routeId: string;
signal?: AbortSignal; signal?: AbortSignal;
intent?: MutationIntent; intent?: MutationIntent;
}>, }>,
): Promise<HttpExecutionOutcome<unknown, unknown>>; ): Promise<HttpExecutionOutcome<WireOutput, Problem>>;
}>; }>;
type FeatureHttpOperationSpec<Value> = Readonly<{ export type FeatureHttpProblemMapping = Readonly<{
operationId: string; kind: FailureKind;
routeId: string; code: string;
mapSuccess(value: unknown): MappingResult<Value>;
}>; }>;
export type FeatureHttpOperation<Input, Value> = type FeatureHttpOperationSpec<
FeatureHttpOperationSpec<Value> & Contract,
Readonly<{ Value,
/** RouteId extends string,
* Compile-time only carrier. Feature bindings remain plain frozen objects > = Readonly<{
* at runtime while preserving each operation's input/value pair. contract: Contract;
*/ routeId: RouteId;
__types?: Readonly<{ mapSuccess(
input: Input; value: HttpContractWireOutput<Contract>,
value: Value; ): MappingResult<Value>;
}>; mapProblem?(
problem: HttpContractProblem<Contract>,
metadata: SafeResponseMetadata,
): FeatureHttpProblemMapping | undefined;
}>;
export type FeatureHttpOperation<
Contract,
Value,
RouteId extends string = string,
> = FeatureHttpOperationSpec<Contract, Value, RouteId> &
Readonly<{
operationId: HttpContractOperationId<Contract>;
/**
* Compile-time only carrier. The runtime object has no extra type payload;
* the contract remains the authority for input/wire/problem types.
*/
__types?: Readonly<{
value: Value;
}>; }>;
}>;
export function defineFeatureHttpOperation<Input, Value>( type ContractValidity<Contract> =
spec: FeatureHttpOperationSpec<Value>, HttpContractOperationId<Contract> extends never
): FeatureHttpOperation<Input, Value> { ? never
return Object.freeze(spec) as FeatureHttpOperation<Input, Value>; : HttpContractInput<Contract> extends never
? never
: HttpContractWireOutput<Contract> extends never
? never
: HttpContractProblem<Contract> extends never
? never
: unknown;
function defineFeatureHttpOperationForRoute<
const RouteId extends string,
const Contract,
Value,
>(
spec: FeatureHttpOperationSpec<Contract, Value, RouteId> &
ContractValidity<Contract>,
): FeatureHttpOperation<Contract, Value, RouteId> {
return Object.freeze({
...spec,
operationId: (spec.contract as Readonly<{
contract: Readonly<{ operationId: HttpContractOperationId<Contract> }>;
}>).contract.operationId,
}) as FeatureHttpOperation<Contract, Value, RouteId>;
} }
type OperationInput<Operation> = export function defineFeatureHttpOperation<
Operation extends FeatureHttpOperation<infer Input, unknown> ? Input : never; const Contract,
Value,
const RouteId extends string,
>(
spec: FeatureHttpOperationSpec<Contract, Value, RouteId> &
ContractValidity<Contract>,
): FeatureHttpOperation<Contract, Value, RouteId> {
return defineFeatureHttpOperationForRoute(spec);
}
/**
* Feature-local route typing without introducing a dependency from the HTTP
* capability to the presentation route registry.
*/
export function defineFeatureHttpOperationForRoutes<
const RouteId extends string,
>() {
return function defineRouteBoundFeatureHttpOperation<
const Contract,
Value,
>(
spec: FeatureHttpOperationSpec<Contract, Value, RouteId> &
ContractValidity<Contract>,
): FeatureHttpOperation<Contract, Value, RouteId> {
return defineFeatureHttpOperationForRoute(spec);
};
}
type FeatureHttpOperationShape = Readonly<{
contract: object;
operationId: string;
routeId: string;
mapSuccess: (...args: never[]) => unknown;
mapProblem?: (...args: never[]) => unknown;
}>;
type OperationContract<Operation> =
Operation extends Readonly<{ contract: infer Contract }> ? Contract : never;
type OperationInput<Operation> = HttpContractInput<OperationContract<Operation>>;
type OperationWireOutput<Operation> =
HttpContractWireOutput<OperationContract<Operation>>;
type OperationProblem<Operation> =
HttpContractProblem<OperationContract<Operation>>;
type OperationValue<Operation> = type OperationValue<Operation> =
Operation extends FeatureHttpOperation<unknown, infer Value> ? Value : never; Operation extends FeatureHttpOperation<infer _Contract, infer Value, string>
? Value
: never;
type InvalidOperationRegistryKeys<
Operations extends Readonly<Record<string, FeatureHttpOperationShape>>,
> = {
[OperationId in keyof Operations & string]:
OperationId extends Operations[OperationId]["operationId"]
? Operations[OperationId]["operationId"] extends OperationId
? never
: OperationId
: OperationId;
}[keyof Operations & string];
type ValidOperationRegistry<
Operations extends Readonly<Record<string, FeatureHttpOperationShape>>,
> = InvalidOperationRegistryKeys<Operations> extends never
? unknown
: Readonly<{
__operationRegistryKeyMismatch: InvalidOperationRegistryKeys<Operations>;
}>;
export type FeatureHttpBinding< export type FeatureHttpBinding<
Operations extends Readonly< Operations extends Readonly<Record<string, FeatureHttpOperationShape>>,
Record<string, FeatureHttpOperation<unknown, unknown>>
>,
> = Readonly<{ > = Readonly<{
execute<OperationId extends keyof Operations & string>( execute<OperationId extends keyof Operations & string>(
operationId: OperationId, operationId: OperationId,
@@ -73,18 +218,15 @@ export type FeatureHttpBinding<
/** /**
* Capability-specific feature binding for the installed HTTP runtime. * Capability-specific feature binding for the installed HTTP runtime.
* *
* The platform owns transport/outcome normalization. A feature contributes only * The installed contract is the single type authority. A feature contributes
* operation identity, route identity, typed input and its wire-to-domain mapper. * only a route identity plus wire-to-domain/problem interpretation. Transport,
* This keeps HTTP lifecycle/failure semantics out of feature application code * retry, validation and effect-certainty stay in the reusable HTTP capability.
* without collapsing storage/realtime/transfer into a universal repository.
*/ */
export function createFeatureHttpBinding< export function createFeatureHttpBinding<
const Operations extends Readonly< const Operations extends Readonly<Record<string, FeatureHttpOperationShape>>,
Record<string, FeatureHttpOperation<unknown, unknown>>
>,
>( >(
executor: InstalledHttpOperationExecutor, executor: InstalledHttpOperationExecutor,
operations: Operations, operations: Operations & ValidOperationRegistry<Operations>,
): FeatureHttpBinding<Operations> { ): FeatureHttpBinding<Operations> {
for (const [registryId, operation] of Object.entries(operations)) { for (const [registryId, operation] of Object.entries(operations)) {
if (registryId !== operation.operationId) { if (registryId !== operation.operationId) {
@@ -104,28 +246,47 @@ export function createFeatureHttpBinding<
}> = {}, }> = {},
): Promise<Result<OperationValue<Operations[OperationId]>, ApiFailure>> { ): Promise<Result<OperationValue<Operations[OperationId]>, ApiFailure>> {
const operation = operations[operationId]; const operation = operations[operationId];
const outcome = await executor.execute(operation.operationId, input, { type SelectedOperation = Operations[OperationId];
type Input = OperationInput<SelectedOperation>;
type WireOutput = OperationWireOutput<SelectedOperation>;
type Problem = OperationProblem<SelectedOperation>;
type Value = OperationValue<SelectedOperation>;
const contract = operation.contract as InstalledHttpContract<
Input,
WireOutput,
Problem
>;
const outcome = await executor.execute(contract, input, {
routeId: operation.routeId, routeId: operation.routeId,
...(context.signal === undefined ? {} : { signal: context.signal }), ...(context.signal === undefined ? {} : { signal: context.signal }),
...(context.intent === undefined ? {} : { intent: context.intent }), ...(context.intent === undefined ? {} : { intent: context.intent }),
}); });
return projectExecutionOutcome< return projectExecutionOutcome(
OperationValue<Operations[OperationId]>
>(
operation.operationId, operation.operationId,
operation.mapSuccess as ( operation.mapSuccess as (value: WireOutput) => MappingResult<Value>,
value: unknown, operation.mapProblem as
) => MappingResult<OperationValue<Operations[OperationId]>>, | ((
problem: Problem,
metadata: SafeResponseMetadata,
) => FeatureHttpProblemMapping | undefined)
| undefined,
outcome, outcome,
); );
}, },
}); });
} }
function projectExecutionOutcome<Value>( function projectExecutionOutcome<WireOutput, Problem, Value>(
operationId: string, operationId: string,
mapSuccess: (value: unknown) => MappingResult<Value>, mapSuccess: (value: WireOutput) => MappingResult<Value>,
outcome: HttpExecutionOutcome<unknown, unknown>, mapProblem:
| ((
problem: Problem,
metadata: SafeResponseMetadata,
) => FeatureHttpProblemMapping | undefined)
| undefined,
outcome: HttpExecutionOutcome<WireOutput, Problem>,
): Result<Value, ApiFailure> { ): Result<Value, ApiFailure> {
switch (outcome.kind) { switch (outcome.kind) {
case "SUCCESS": { case "SUCCESS": {
@@ -139,13 +300,15 @@ function projectExecutionOutcome<Value>(
{ effect: outcome.effect }, { effect: outcome.effect },
); );
} }
case "PROBLEM": case "PROBLEM": {
const mapped = mapProblem?.(outcome.problem, outcome.metadata);
return failure( return failure(
kindForStatus(outcome.metadata.status), mapped?.kind ?? kindForStatus(outcome.metadata.status),
operationId, operationId,
"CONTRACT_PROBLEM", mapped?.code ?? "CONTRACT_PROBLEM",
{ httpStatus: outcome.metadata.status, effect: outcome.effect }, { httpStatus: outcome.metadata.status, effect: outcome.effect },
); );
}
case "UNAUTHENTICATED": case "UNAUTHENTICATED":
return failure("AUTH_REQUIRED", operationId, "UNAUTHENTICATED", { return failure("AUTH_REQUIRED", operationId, "UNAUTHENTICATED", {
effect: outcome.effect, effect: outcome.effect,
+6
View File
@@ -22,8 +22,14 @@ export {
export { export {
createFeatureHttpBinding, createFeatureHttpBinding,
defineFeatureHttpOperation, defineFeatureHttpOperation,
defineFeatureHttpOperationForRoutes,
type FeatureHttpBinding, type FeatureHttpBinding,
type FeatureHttpOperation, type FeatureHttpOperation,
type FeatureHttpProblemMapping,
type HttpContractInput,
type HttpContractOperationId,
type HttpContractProblem,
type HttpContractWireOutput,
type InstalledHttpOperationExecutor, type InstalledHttpOperationExecutor,
} from "./feature-http-binding.ts"; } from "./feature-http-binding.ts";
/** V3 `attachCredentials` 콜백이 반환해야 하는 결과 타입. */ /** V3 `attachCredentials` 콜백이 반환해야 하는 결과 타입. */
@@ -21,9 +21,23 @@ export type IndexedDbSynchronizationState =
| "CONFIRMED"; | "CONFIRMED";
export type IndexedDbConnectionStatus = export type IndexedDbConnectionStatus =
| Readonly<{ kind: "CLOSED"; reason: "NOT_OPENED" | "VERSION_CHANGE" | "FORCED" }> | Readonly<{
kind: "CLOSED";
/**
* NOT_OPENED means the current open attempt has settled without a live
* connection. A blocked upgrade may therefore be observed as BLOCKED
* while it is waiting, then transition to CLOSED/NOT_OPENED when its
* blocked deadline fails closed.
*/
reason: "NOT_OPENED" | "VERSION_CHANGE" | "FORCED";
}>
| Readonly<{ kind: "OPENING"; targetVersion: number }> | Readonly<{ kind: "OPENING"; targetVersion: number }>
| Readonly<{ | Readonly<{
/**
* A live open/upgrade attempt is currently waiting for another
* IndexedDB context. BLOCKED is transient and is not the settled state
* after the blocked deadline has expired.
*/
kind: "BLOCKED"; kind: "BLOCKED";
currentVersion: number; currentVersion: number;
targetVersion: number; targetVersion: number;
+14 -15
View File
@@ -9,6 +9,7 @@ import {
createContractHttpExecutor, createContractHttpExecutor,
createHttpClient, createHttpClient,
type HttpExecutionObservation, type HttpExecutionObservation,
type InstalledHttpOperationExecutor,
} from "../adapters/http/index.ts"; } from "../adapters/http/index.ts";
import { import {
attemptBucket, attemptBucket,
@@ -480,19 +481,13 @@ export async function createRuntimeAdapters(
}, },
observe: createHttpObservationProjector({ diagnostics, telemetry }), observe: createHttpObservationProjector({ diagnostics, telemetry }),
}); });
const contractOperations = Object.freeze({ const contractOperations: InstalledHttpOperationExecutor = Object.freeze({
async execute( async execute(operation, input, executionContext) {
operationId: string, const installed =
input: unknown, COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(
executionContext: Readonly<{ operation.contract.operationId,
routeId: string; );
signal?: AbortSignal; if (!installed) {
intent?: MutationIntent;
}>,
) {
const operation =
COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(operationId);
if (!operation) {
return Object.freeze({ return Object.freeze({
kind: "CONTRACT_VIOLATION" as const, kind: "CONTRACT_VIOLATION" as const,
effect: "NOT_STARTED" as const, effect: "NOT_STARTED" as const,
@@ -502,7 +497,11 @@ export async function createRuntimeAdapters(
}), }),
}); });
} }
const outcome = await contractHttp.execute(operation, input, { // composeContractContributions deliberately snapshots every installed
// contract. Runtime authority is therefore the validated registry entry,
// not object identity with the feature-owned source declaration.
const executableContract = installed as typeof operation;
const outcome = await contractHttp.execute(executableContract, input, {
routeId: executionContext.routeId, routeId: executionContext.routeId,
scope: serverStateScope.getSnapshot(), scope: serverStateScope.getSnapshot(),
...(executionContext.signal === undefined ...(executionContext.signal === undefined
@@ -519,7 +518,7 @@ export async function createRuntimeAdapters(
}, },
}); });
const featureInputs = createInstalledFeatureInputs({ const featureInputs = createInstalledFeatureInputs({
contractOperations, http: contractOperations,
}); });
return Object.freeze({ return Object.freeze({
@@ -0,0 +1,176 @@
import type {
ApplicationFeatureId,
ApplicationFeatureInputs,
} from "../application/ports/in/application-api.ts";
import type {
InstalledHttpOperationExecutor,
} from "../adapters/http/index.ts";
/**
* Features augment this registry with the repository identities they consume.
* The provider is a composition capability; feature use cases never import it.
*/
export interface FeatureIndexedDbRepositories {}
export type FeatureIndexedDbRepositoryId = Extract<
keyof FeatureIndexedDbRepositories,
string
>;
export type IndexedDbRepositoryProvider = Readonly<{
get<RepositoryId extends FeatureIndexedDbRepositoryId>(
repositoryId: RepositoryId,
): FeatureIndexedDbRepositories[RepositoryId];
}>;
export function createIndexedDbRepositoryProvider(
repositories: Readonly<Partial<FeatureIndexedDbRepositories>>,
): IndexedDbRepositoryProvider {
const snapshot = Object.freeze({ ...repositories });
return Object.freeze({
get<RepositoryId extends FeatureIndexedDbRepositoryId>(
repositoryId: RepositoryId,
): FeatureIndexedDbRepositories[RepositoryId] {
const repository = snapshot[repositoryId];
if (!repository) {
throw new TypeError(
`IndexedDB repository is not installed: ${repositoryId}`,
);
}
return repository as FeatureIndexedDbRepositories[RepositoryId];
},
});
}
export type FeaturePlatformCapabilities = Readonly<{
http: InstalledHttpOperationExecutor;
indexedDb: IndexedDbRepositoryProvider;
}>;
export type FeaturePlatformCapabilityId =
keyof FeaturePlatformCapabilities;
export type FeatureCapabilitySelection<
Needs extends readonly FeaturePlatformCapabilityId[],
> = Pick<FeaturePlatformCapabilities, Needs[number]>;
const FEATURE_ADAPTER_CONTRIBUTION = Symbol(
"feature-adapter-contribution",
);
type FeatureAdapterContributionDefinition<
FeatureId extends ApplicationFeatureId,
Needs extends readonly FeaturePlatformCapabilityId[],
> = Readonly<{
featureId: FeatureId;
needs: Needs;
createInput(
context: FeatureCapabilitySelection<Needs>,
): Readonly<{
featureId: FeatureId;
input: ApplicationFeatureInputs[FeatureId];
}>;
}>;
export type FeatureAdapterContribution<
FeatureId extends ApplicationFeatureId,
Needs extends readonly FeaturePlatformCapabilityId[],
> = FeatureAdapterContributionDefinition<FeatureId, Needs> &
Readonly<{
[FEATURE_ADAPTER_CONTRIBUTION]: true;
}>;
export function defineFeatureAdapterContribution<
const FeatureId extends ApplicationFeatureId,
const Needs extends readonly FeaturePlatformCapabilityId[],
>(
contribution: FeatureAdapterContributionDefinition<FeatureId, Needs>,
): FeatureAdapterContribution<FeatureId, Needs> {
return Object.freeze({
...contribution,
needs: Object.freeze([...contribution.needs]) as unknown as Needs,
[FEATURE_ADAPTER_CONTRIBUTION]: true as const,
});
}
type ContributionShape = Readonly<{
[FEATURE_ADAPTER_CONTRIBUTION]: true;
featureId: ApplicationFeatureId;
needs: readonly FeaturePlatformCapabilityId[];
createInput: (...args: never[]) => unknown;
}>;
type ContributionNeeds<Contribution> =
Contribution extends Readonly<{
needs: readonly (infer Capability)[];
}>
? Extract<Capability, FeaturePlatformCapabilityId>
: never;
export type ContributionsCapabilityContext<
Contributions extends readonly ContributionShape[],
> = Pick<
FeaturePlatformCapabilities,
ContributionNeeds<Contributions[number]>
>;
export type InstalledFeatureInputs = Readonly<
Partial<ApplicationFeatureInputs>
>;
function setFeatureInput<FeatureId extends ApplicationFeatureId>(
target: Partial<ApplicationFeatureInputs>,
featureId: FeatureId,
input: ApplicationFeatureInputs[FeatureId],
): void {
const writable = target as Record<
ApplicationFeatureId,
ApplicationFeatureInputs[ApplicationFeatureId]
>;
writable[featureId] = input;
}
function invokeContribution<
FeatureId extends ApplicationFeatureId,
Needs extends readonly FeaturePlatformCapabilityId[],
>(
contribution: FeatureAdapterContribution<FeatureId, Needs>,
context: FeaturePlatformCapabilities,
) {
return contribution.createInput(context);
}
/**
* Dynamic catalog assembly stays centralized here. Type authority lives at each
* feature contribution; the only dynamic write is isolated in setFeatureInput.
*/
export function composeFeatureAdapterInputs<
const Contributions extends readonly ContributionShape[],
>(
contributions: Contributions,
enabledFeatureIds: readonly string[],
context: ContributionsCapabilityContext<Contributions>,
): InstalledFeatureInputs {
const enabled = new Set<string>(enabledFeatureIds);
const result: Partial<ApplicationFeatureInputs> = {};
for (const contribution of contributions) {
if (contribution[FEATURE_ADAPTER_CONTRIBUTION] !== true) {
throw new TypeError(
"Feature adapter contribution must be defined through defineFeatureAdapterContribution()",
);
}
if (!enabled.has(contribution.featureId)) continue;
const installed = invokeContribution(
contribution as unknown as FeatureAdapterContribution<
ApplicationFeatureId,
readonly FeaturePlatformCapabilityId[]
>,
context as FeaturePlatformCapabilities,
);
setFeatureInput(result, installed.featureId, installed.input);
}
return Object.freeze(result);
}
+17 -19
View File
@@ -1,4 +1,8 @@
import type { ApplicationFeatureInputs } from "../application/ports/in/application-api.ts"; import {
composeFeatureAdapterInputs,
type ContributionsCapabilityContext,
type InstalledFeatureInputs,
} from "./feature-adapter-contribution.ts";
import { REFERENCE_FEATURE_ADAPTER_CONTRIBUTION } from "./reference-feature/adapters/create-reference-feature-input.ts"; import { REFERENCE_FEATURE_ADAPTER_CONTRIBUTION } from "./reference-feature/adapters/create-reference-feature-input.ts";
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts"; import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
@@ -6,28 +10,22 @@ const COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS = Object.freeze([
REFERENCE_FEATURE_ADAPTER_CONTRIBUTION, REFERENCE_FEATURE_ADAPTER_CONTRIBUTION,
] as const); ] as const);
type FeatureAdapterContext = Parameters< type InstalledFeatureCapabilityContext =
(typeof COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS)[number]["createInput"] ContributionsCapabilityContext<
>[0]; typeof COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS
>;
type InstalledFeatureInputs = Readonly<Partial<ApplicationFeatureInputs>>;
/** /**
* Central adapter composition only selects and aggregates feature-owned * Central adapter composition only selects and aggregates feature-owned
* contributions. The feature owns how its application input is bound to * contributions. The feature owns how its application input is bound to the
* reusable platform capabilities. * reusable platform capabilities declared by its `needs` list.
*/ */
export function createInstalledFeatureInputs( export function createInstalledFeatureInputs(
context: FeatureAdapterContext, context: InstalledFeatureCapabilityContext,
): InstalledFeatureInputs { ): InstalledFeatureInputs {
const entries = COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS return composeFeatureAdapterInputs(
.filter((contribution) => COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS,
INSTALLED_PRODUCT_FEATURE_IDS.includes(contribution.featureId), INSTALLED_PRODUCT_FEATURE_IDS,
) context,
.map((contribution) => { );
const installed = contribution.createInput(context);
return [installed.featureId, installed.input] as const;
});
return Object.freeze(Object.fromEntries(entries)) as InstalledFeatureInputs;
} }
@@ -0,0 +1,41 @@
import type {
IndexedDbRepositoryPort,
} from "../../../application/ports/browser-file-storage/index.ts";
import {
defineFeatureAdapterContribution,
} from "../../feature-adapter-contribution.ts";
import type {} from "../../feature-adapter-contribution.ts";
import {
createLocalDraftFeatureInput,
} from "../application/local-draft-api.ts";
import type { LocalDraft } from "../domain/local-draft.ts";
import {
LOCAL_DRAFT_FEATURE_ID,
LOCAL_DRAFT_REPOSITORY_ID,
} from "../contracts/local-draft-contract.ts";
import { createLocalDraftStore } from "./local-draft-indexeddb-store.ts";
declare module "../../feature-adapter-contribution.ts" {
interface FeatureIndexedDbRepositories {
"local-draft": IndexedDbRepositoryPort<LocalDraft, never>;
}
}
export function createLocalDraftFeatureInstalledInput(context: Readonly<{
indexedDb: Readonly<{
get(repositoryId: "local-draft"): IndexedDbRepositoryPort<LocalDraft, never>;
}>;
}>) {
const repository = context.indexedDb.get(LOCAL_DRAFT_REPOSITORY_ID);
return Object.freeze({
featureId: LOCAL_DRAFT_FEATURE_ID,
input: createLocalDraftFeatureInput(createLocalDraftStore(repository)),
});
}
export const LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION =
defineFeatureAdapterContribution({
featureId: LOCAL_DRAFT_FEATURE_ID,
needs: ["indexedDb"] as const,
createInput: createLocalDraftFeatureInstalledInput,
});
@@ -0,0 +1,105 @@
import type {
BrowserDataFailure,
IndexedDbRepositoryPort,
} from "../../../application/ports/browser-file-storage/index.ts";
import {
createFailure,
type AppFailure,
type FailureKind,
} from "../../../contracts/errors.ts";
import type { Result } from "../../../contracts/result.ts";
import type {
LocalDraftStore,
LocalDraftRecord,
RemoveLocalDraftCommand,
SaveLocalDraftCommand,
} from "../application/local-draft-api.ts";
import type { LocalDraft } from "../domain/local-draft.ts";
function failureKind(failure: BrowserDataFailure): FailureKind {
if (failure.code === "QUOTA_EXCEEDED") return "STORAGE_QUOTA_EXCEEDED";
if (failure.code === "CONFLICT") return "CONFLICT";
if (failure.code === "ABORTED") return "REQUEST_ABORTED";
return "STORAGE_UNAVAILABLE";
}
function mapFailure(
failure: BrowserDataFailure,
operationId: string,
): AppFailure {
return createFailure(failureKind(failure), operationId, 0, {
code: `INDEXEDDB_${failure.code}`,
});
}
function failed<Value>(
failure: BrowserDataFailure,
operationId: string,
): Result<Value> {
return Object.freeze({
ok: false as const,
error: mapFailure(failure, operationId),
});
}
/**
* Feature-owned adapter over the reusable IndexedDB repository capability.
* Native IDB objects, migrations, connection leases and quota internals do not
* cross this file.
*/
export function createLocalDraftStore(
repository: IndexedDbRepositoryPort<LocalDraft, never>,
): LocalDraftStore {
return Object.freeze({
async save(command: SaveLocalDraftCommand, signal?: AbortSignal) {
const result = await repository.compareAndSwap({
key: command.draft.draftId,
value: command.draft,
expectedRevision: command.expectedRevision,
idempotencyKey: command.idempotencyKey,
signal,
});
return result.ok
? Object.freeze({
ok: true as const,
value: Object.freeze({ revision: result.value.revision }),
})
: failed<Readonly<{ revision: number }>>(
result.error,
"LOCAL_DRAFT_SAVE",
);
},
async find(draftId: string, signal?: AbortSignal) {
const result = await repository.read(draftId, signal);
if (!result.ok) {
return failed<LocalDraftRecord | null>(
result.error,
"LOCAL_DRAFT_FIND",
);
}
return Object.freeze({
ok: true as const,
value:
result.value === null
? null
: Object.freeze({
draft: result.value.value,
revision: result.value.revision,
}),
});
},
async remove(command: RemoveLocalDraftCommand, signal?: AbortSignal) {
const result = await repository.remove({
key: command.draftId,
expectedRevision: command.expectedRevision,
idempotencyKey: command.idempotencyKey,
signal,
});
return result.ok
? Object.freeze({ ok: true as const, value: undefined })
: failed<void>(result.error, "LOCAL_DRAFT_REMOVE");
},
});
}
@@ -0,0 +1,72 @@
import type { Result } from "../../../contracts/result.ts";
import type {} from "../../../application/ports/in/application-api.ts";
import type { LocalDraft } from "../domain/local-draft.ts";
export type LocalDraftRecord = Readonly<{
draft: LocalDraft;
revision: number;
}>;
export type SaveLocalDraftCommand = Readonly<{
draft: LocalDraft;
expectedRevision: number | null;
idempotencyKey: string;
}>;
export type RemoveLocalDraftCommand = Readonly<{
draftId: string;
expectedRevision: number;
idempotencyKey: string;
}>;
export type LocalDraftStore = Readonly<{
save(
command: SaveLocalDraftCommand,
signal?: AbortSignal,
): Promise<Result<Readonly<{ revision: number }>>>;
find(
draftId: string,
signal?: AbortSignal,
): Promise<Result<LocalDraftRecord | null>>;
remove(
command: RemoveLocalDraftCommand,
signal?: AbortSignal,
): Promise<Result<void>>;
}>;
export type LocalDraftFeatureInput = Readonly<{
saveDraft(
command: SaveLocalDraftCommand,
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<Result<Readonly<{ revision: number }>>>;
findDraft(
draftId: string,
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<Result<LocalDraftRecord | null>>;
removeDraft(
command: RemoveLocalDraftCommand,
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<Result<void>>;
}>;
declare module "../../../application/ports/in/application-api.ts" {
interface ApplicationFeatureInputs {
"local-draft": LocalDraftFeatureInput;
}
}
export function createLocalDraftFeatureInput(
store: LocalDraftStore,
): LocalDraftFeatureInput {
return Object.freeze({
saveDraft(command, context) {
return store.save(command, context?.signal);
},
findDraft(draftId, context) {
return store.find(draftId, context?.signal);
},
removeDraft(command, context) {
return store.remove(command, context?.signal);
},
});
}
@@ -0,0 +1,2 @@
export const LOCAL_DRAFT_FEATURE_ID = "local-draft" as const;
export const LOCAL_DRAFT_REPOSITORY_ID = "local-draft" as const;
@@ -0,0 +1,21 @@
export type LocalDraft = Readonly<{
draftId: string;
title: string;
body: string;
}>;
export function createLocalDraft(values: LocalDraft): LocalDraft {
const draftId = values.draftId.trim();
const title = values.title.trim();
if (!draftId || !title) {
throw new TypeError("Local draft requires draftId and title");
}
if (title.length > 160 || values.body.length > 100_000) {
throw new TypeError("Local draft exceeds bounded field limits");
}
return Object.freeze({
draftId,
title,
body: values.body,
});
}
+4 -2
View File
@@ -19,8 +19,10 @@ augmentation으로 `"reference-feature": ReferenceFeatureInput`을 기여하므
별도 cast나 runtime shape 확인 없이 정확한 input type을 받는다. 다만 동적 호출로 별도 cast나 runtime shape 확인 없이 정확한 input type을 받는다. 다만 동적 호출로
설치되지 않은 ID가 들어오는 경우를 위해 `get`의 runtime guard도 유지한다. 설치되지 않은 ID가 들어오는 경우를 위해 `get`의 runtime guard도 유지한다.
예측 가능한 실패는 `src/application/result.ts`의 공통 예측 가능한 실패는 canonical shared contract인
`Result<Value, Failure = AppFailure>`로 반환한다. `AppFailure.kind` `src/contracts/result.ts``Result<Value, Failure = AppFailure>`로 반환한다.
`src/application/result.ts`는 기존 호출자를 위한 deprecated migration shim일
뿐 새 코드의 import 경로가 아니다. `AppFailure.kind`
`ERROR_REGISTRY` key에서 파생된 닫힌 vocabulary이며, transport 호환 이름인 `ERROR_REGISTRY` key에서 파생된 닫힌 vocabulary이며, transport 호환 이름인
`ApiFailure`는 같은 type의 alias다. `ApiFailure`는 같은 type의 alias다.
@@ -2,6 +2,9 @@ import {
createFeatureHttpBinding, createFeatureHttpBinding,
type InstalledHttpOperationExecutor, type InstalledHttpOperationExecutor,
} from "../../../adapters/http/index.ts"; } from "../../../adapters/http/index.ts";
import {
defineFeatureAdapterContribution,
} from "../../feature-adapter-contribution.ts";
import { createReferenceFeatureInput } from "../application/reference-feature-api.ts"; import { createReferenceFeatureInput } from "../application/reference-feature-api.ts";
import { import {
REFERENCE_FEATURE_ID, REFERENCE_FEATURE_ID,
@@ -17,15 +20,14 @@ export type InstalledContractOperationExecutor =
/** /**
* Feature-owned composition seam. * Feature-owned composition seam.
* *
* The feature contributes typed operation descriptors and its application * The feature receives only the HTTP capability it declares in `needs`.
* gateway. HTTP lifecycle/error normalization stays in the reusable capability * HTTP lifecycle/error normalization stays in the reusable capability binding.
* binding rather than being repeated by every product feature.
*/ */
export function createReferenceFeatureInstalledInput(context: Readonly<{ export function createReferenceFeatureInstalledInput(context: Readonly<{
contractOperations: InstalledHttpOperationExecutor; http: InstalledHttpOperationExecutor;
}>) { }>) {
const http = createFeatureHttpBinding( const http = createFeatureHttpBinding(
context.contractOperations, context.http,
REFERENCE_HTTP_OPERATIONS, REFERENCE_HTTP_OPERATIONS,
); );
@@ -35,7 +37,9 @@ export function createReferenceFeatureInstalledInput(context: Readonly<{
}); });
} }
export const REFERENCE_FEATURE_ADAPTER_CONTRIBUTION = Object.freeze({ export const REFERENCE_FEATURE_ADAPTER_CONTRIBUTION =
featureId: REFERENCE_FEATURE_ID, defineFeatureAdapterContribution({
createInput: createReferenceFeatureInstalledInput, featureId: REFERENCE_FEATURE_ID,
}); needs: ["http"] as const,
createInput: createReferenceFeatureInstalledInput,
});
@@ -1,40 +1,57 @@
import { import {
defineFeatureHttpOperation, defineFeatureHttpOperationForRoutes,
type FeatureHttpBinding, type FeatureHttpBinding,
} from "../../../adapters/http/index.ts"; } from "../../../adapters/http/index.ts";
import type { import {
ReferenceCreateCommand, CREATE_REFERENCE_RESOURCE_CONTRACT,
ReferenceGateway, GET_REFERENCE_RESOURCE_CONTRACT,
ReferenceListFilters, LIST_REFERENCE_RESOURCES_CONTRACT,
} from "../application/reference-feature-api.ts"; } from "../contracts/reference-feature-contract-contribution.ts";
import type { ReferenceResource } from "../domain/reference-resource.ts"; import {
REFERENCE_FEATURE_CONTRACT,
} from "../contracts/reference-feature-contract.ts";
import { import {
mapReferenceResourceListPayload, mapReferenceResourceListPayload,
mapReferenceResourcePayload, mapReferenceResourcePayload,
} from "../contracts/reference-mapper.ts"; } from "../contracts/reference-mapper.ts";
import type {
ReferenceGateway,
} from "../application/reference-feature-api.ts";
export type ReferenceFeatureRouteId =
keyof typeof REFERENCE_FEATURE_CONTRACT.routes;
const defineReferenceHttpOperation =
defineFeatureHttpOperationForRoutes<ReferenceFeatureRouteId>();
export const REFERENCE_HTTP_OPERATIONS = Object.freeze({ export const REFERENCE_HTTP_OPERATIONS = Object.freeze({
LIST_REFERENCE_RESOURCES: defineFeatureHttpOperation< LIST_REFERENCE_RESOURCES: defineReferenceHttpOperation({
ReferenceListFilters, contract: LIST_REFERENCE_RESOURCES_CONTRACT,
readonly ReferenceResource[]
>({
operationId: "LIST_REFERENCE_RESOURCES",
routeId: "REFERENCE_RESOURCE_LIST", routeId: "REFERENCE_RESOURCE_LIST",
mapSuccess: mapReferenceResourceListPayload, mapSuccess: mapReferenceResourceListPayload,
}), }),
CREATE_REFERENCE_RESOURCE: defineFeatureHttpOperation< CREATE_REFERENCE_RESOURCE: defineReferenceHttpOperation({
ReferenceCreateCommand, contract: CREATE_REFERENCE_RESOURCE_CONTRACT,
ReferenceResource
>({
operationId: "CREATE_REFERENCE_RESOURCE",
routeId: "REFERENCE_RESOURCE_LIST", routeId: "REFERENCE_RESOURCE_LIST",
mapSuccess: mapReferenceResourcePayload, mapSuccess: mapReferenceResourcePayload,
mapProblem(problem, metadata) {
if (metadata.status === 409) {
return Object.freeze({
kind: "CONFLICT" as const,
code: problem.code ?? "REFERENCE_RESOURCE_CONFLICT",
});
}
if (metadata.status === 422) {
return Object.freeze({
kind: "VALIDATION_REJECTED" as const,
code: problem.code ?? "REFERENCE_RESOURCE_REJECTED",
});
}
return undefined;
},
}), }),
GET_REFERENCE_RESOURCE: defineFeatureHttpOperation< GET_REFERENCE_RESOURCE: defineReferenceHttpOperation({
Readonly<{ resourceId: string }>, contract: GET_REFERENCE_RESOURCE_CONTRACT,
ReferenceResource
>({
operationId: "GET_REFERENCE_RESOURCE",
routeId: "REFERENCE_RESOURCE_DETAIL", routeId: "REFERENCE_RESOURCE_DETAIL",
mapSuccess: mapReferenceResourcePayload, mapSuccess: mapReferenceResourcePayload,
}), }),
@@ -2,17 +2,22 @@ import { z } from "zod";
import type { import type {
CommandEffectDescriptor, CommandEffectDescriptor,
HttpRequestProjection,
InstalledContractContribution, InstalledContractContribution,
InstalledHttpContract, InstalledHttpContract,
RuntimeValidator, RuntimeValidator,
} from "../../../contracts/external-contract-runtime.ts"; } from "../../../contracts/external-contract-runtime.ts";
import { REFERENCE_FEATURE_ID } from "./reference-feature-contract.ts"; import { REFERENCE_FEATURE_ID } from "./reference-feature-contract.ts";
import { import {
createReferenceResourceCommandSchema,
referenceProblemSchema,
referenceResourceDtoSchema,
referenceResourceListQuerySchema, referenceResourceListQuerySchema,
referenceResourceParamsSchema, referenceResourceParamsSchema,
type ReferenceProblem,
} from "./reference-schemas.ts"; } from "./reference-schemas.ts";
export type { ReferenceProblem } from "./reference-schemas.ts";
/** /**
* §4.8. The single `TEMPLATE_FIXTURE` contribution. It keeps the reference * §4.8. The single `TEMPLATE_FIXTURE` contribution. It keeps the reference
* HTTP vertical executable as deterministic template data and is excluded from * HTTP vertical executable as deterministic template data and is excluded from
@@ -53,33 +58,10 @@ function zodValidator<T>(
}); });
} }
const referenceResourceDto = z const PROBLEM_VALIDATOR = zodValidator(
.object({ "ReferenceProblem",
id: z.string().min(1).max(120), referenceProblemSchema,
name: z.string().min(1).max(240), );
createdAt: z.string().min(1).optional(),
})
.strip();
const problemSchema = z
.object({
type: z.string().min(1).max(512),
title: z.string().min(1).max(240),
status: z.int().min(100).max(599),
code: z.string().min(1).max(120).optional(),
})
.strip();
export type ReferenceProblem = z.output<typeof problemSchema>;
const PROBLEM_VALIDATOR = zodValidator("ReferenceProblem", problemSchema);
const createCommandSchema = z
.object({
name: z.string().trim().min(1).max(120),
note: z.string().trim().max(500).optional(),
})
.strict();
/** /**
* Fixture-side classifier standing in for the package-provided pure bounded * Fixture-side classifier standing in for the package-provided pure bounded
@@ -87,17 +69,17 @@ const createCommandSchema = z
*/ */
const CREATE_EFFECT: CommandEffectDescriptor<ReferenceProblem> = Object.freeze({ const CREATE_EFFECT: CommandEffectDescriptor<ReferenceProblem> = Object.freeze({
successEffect: "APPLIED_CONFIRMED" as const, successEffect: "APPLIED_CONFIRMED" as const,
classifyProblem({ status }: Readonly<{ status: number; problem: ReferenceProblem }>) { classifyProblem({
if (status === 400 || status === 409 || status === 422) return "NOT_APPLIED"; status,
}: Readonly<{ status: number; problem: ReferenceProblem }>) {
if (status === 400 || status === 409 || status === 422) {
return "NOT_APPLIED";
}
return "MAYBE_APPLIED"; return "MAYBE_APPLIED";
}, },
}); });
const LIST_REFERENCE_RESOURCES: InstalledHttpContract< export const LIST_REFERENCE_RESOURCES_CONTRACT = Object.freeze({
z.output<typeof referenceResourceListQuerySchema>,
readonly z.output<typeof referenceResourceDto>[],
ReferenceProblem
> = Object.freeze({
contract: Object.freeze({ contract: Object.freeze({
operationId: "LIST_REFERENCE_RESOURCES", operationId: "LIST_REFERENCE_RESOURCES",
method: "GET" as const, method: "GET" as const,
@@ -108,7 +90,7 @@ const LIST_REFERENCE_RESOURCES: InstalledHttpContract<
), ),
outputValidator: zodValidator( outputValidator: zodValidator(
"ReferenceResourceListPayload", "ReferenceResourceListPayload",
z.array(referenceResourceDto).max(100), z.array(referenceResourceDtoSchema).max(100),
), ),
problemValidator: PROBLEM_VALIDATOR, problemValidator: PROBLEM_VALIDATOR,
acceptedStatuses: Object.freeze([200]), acceptedStatuses: Object.freeze([200]),
@@ -139,13 +121,13 @@ const LIST_REFERENCE_RESOURCES: InstalledHttpContract<
authProfileId: "REFERENCE_EXTERNAL_BEARER", authProfileId: "REFERENCE_EXTERNAL_BEARER",
diagnosticsOperation: "reference.list", diagnosticsOperation: "reference.list",
}), }),
}); }) satisfies InstalledHttpContract<
z.output<typeof referenceResourceListQuerySchema>,
const GET_REFERENCE_RESOURCE: InstalledHttpContract< readonly z.output<typeof referenceResourceDtoSchema>[],
z.output<typeof referenceResourceParamsSchema>,
z.output<typeof referenceResourceDto>,
ReferenceProblem ReferenceProblem
> = Object.freeze({ >;
export const GET_REFERENCE_RESOURCE_CONTRACT = Object.freeze({
contract: Object.freeze({ contract: Object.freeze({
operationId: "GET_REFERENCE_RESOURCE", operationId: "GET_REFERENCE_RESOURCE",
method: "GET" as const, method: "GET" as const,
@@ -156,7 +138,7 @@ const GET_REFERENCE_RESOURCE: InstalledHttpContract<
), ),
outputValidator: zodValidator( outputValidator: zodValidator(
"ReferenceResourcePayload", "ReferenceResourcePayload",
referenceResourceDto, referenceResourceDtoSchema,
), ),
problemValidator: PROBLEM_VALIDATOR, problemValidator: PROBLEM_VALIDATOR,
acceptedStatuses: Object.freeze([200]), acceptedStatuses: Object.freeze([200]),
@@ -183,24 +165,24 @@ const GET_REFERENCE_RESOURCE: InstalledHttpContract<
authProfileId: "REFERENCE_EXTERNAL_BEARER", authProfileId: "REFERENCE_EXTERNAL_BEARER",
diagnosticsOperation: "reference.detail", diagnosticsOperation: "reference.detail",
}), }),
}); }) satisfies InstalledHttpContract<
z.output<typeof referenceResourceParamsSchema>,
const CREATE_REFERENCE_RESOURCE: InstalledHttpContract< z.output<typeof referenceResourceDtoSchema>,
z.output<typeof createCommandSchema>,
z.output<typeof referenceResourceDto>,
ReferenceProblem ReferenceProblem
> = Object.freeze({ >;
export const CREATE_REFERENCE_RESOURCE_CONTRACT = Object.freeze({
contract: Object.freeze({ contract: Object.freeze({
operationId: "CREATE_REFERENCE_RESOURCE", operationId: "CREATE_REFERENCE_RESOURCE",
method: "POST" as const, method: "POST" as const,
pathTemplate: "/api/reference-resources", pathTemplate: "/api/reference-resources",
inputValidator: zodValidator( inputValidator: zodValidator(
"CreateReferenceResourceCommand", "CreateReferenceResourceCommand",
createCommandSchema, createReferenceResourceCommandSchema,
), ),
outputValidator: zodValidator( outputValidator: zodValidator(
"ReferenceResourcePayload", "ReferenceResourcePayload",
referenceResourceDto, referenceResourceDtoSchema,
), ),
problemValidator: PROBLEM_VALIDATOR, problemValidator: PROBLEM_VALIDATOR,
acceptedStatuses: Object.freeze([200, 201]), acceptedStatuses: Object.freeze([200, 201]),
@@ -213,7 +195,7 @@ const CREATE_REFERENCE_RESOURCE: InstalledHttpContract<
operationIdentityField: "idempotencyKey", operationIdentityField: "idempotencyKey",
}), }),
commandEffect: CREATE_EFFECT, commandEffect: CREATE_EFFECT,
projectRequest(input: z.output<typeof createCommandSchema>) { projectRequest(input: z.output<typeof createReferenceResourceCommandSchema>) {
return Object.freeze({ return Object.freeze({
pathValues: Object.freeze({}), pathValues: Object.freeze({}),
queryEntries: Object.freeze([]), queryEntries: Object.freeze([]),
@@ -235,7 +217,11 @@ const CREATE_REFERENCE_RESOURCE: InstalledHttpContract<
authProfileId: "REFERENCE_EXTERNAL_BEARER", authProfileId: "REFERENCE_EXTERNAL_BEARER",
diagnosticsOperation: "reference.create", diagnosticsOperation: "reference.create",
}), }),
}); }) satisfies InstalledHttpContract<
z.output<typeof createReferenceResourceCommandSchema>,
z.output<typeof referenceResourceDtoSchema>,
ReferenceProblem
>;
export const REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION: InstalledContractContribution = export const REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION: InstalledContractContribution =
Object.freeze({ Object.freeze({
@@ -247,9 +233,9 @@ export const REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION: InstalledContractContribut
revision: 1 as const, revision: 1 as const,
}), }),
http: Object.freeze([ http: Object.freeze([
LIST_REFERENCE_RESOURCES, LIST_REFERENCE_RESOURCES_CONTRACT,
GET_REFERENCE_RESOURCE, GET_REFERENCE_RESOURCE_CONTRACT,
CREATE_REFERENCE_RESOURCE, CREATE_REFERENCE_RESOURCE_CONTRACT,
]) as readonly InstalledHttpContract<unknown, unknown, unknown>[], ]) as readonly InstalledHttpContract<unknown, unknown, unknown>[],
events: Object.freeze([]), events: Object.freeze([]),
}); });
@@ -8,6 +8,7 @@ import {
type InstalledBoundaryMapper, type InstalledBoundaryMapper,
type MappingResult, type MappingResult,
} from "../../../contracts/boundary-mapper.ts"; } from "../../../contracts/boundary-mapper.ts";
import type { ReferenceResourceDto } from "./reference-schemas.ts";
export type ReferenceResourceView = Readonly<{ export type ReferenceResourceView = Readonly<{
resourceId: string; resourceId: string;
@@ -16,22 +17,19 @@ export type ReferenceResourceView = Readonly<{
optimistic?: boolean; optimistic?: boolean;
}>; }>;
/**
* Operation-specific mapper. The HTTP contract has already validated the wire
* shape, so this mapper owns only wire -> domain construction.
*/
export function mapReferenceResourcePayload( export function mapReferenceResourcePayload(
value: unknown, dto: ReferenceResourceDto,
): MappingResult<ReferenceResource> { ): MappingResult<ReferenceResource> {
if (!value || typeof value !== "object") {
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
const dto = value as Record<string, unknown>;
if (typeof dto.id !== "string" || typeof dto.name !== "string") {
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
try { try {
return mappingSuccess( return mappingSuccess(
createReferenceResource({ createReferenceResource({
id: dto.id, id: dto.id,
displayName: dto.name, displayName: dto.name,
createdAt: typeof dto.createdAt === "string" ? dto.createdAt : null, createdAt: dto.createdAt ?? null,
}), }),
); );
} catch { } catch {
@@ -40,14 +38,8 @@ export function mapReferenceResourcePayload(
} }
export function mapReferenceResourceListPayload( export function mapReferenceResourceListPayload(
payload: unknown, payload: readonly ReferenceResourceDto[],
): MappingResult<readonly ReferenceResource[]> { ): MappingResult<readonly ReferenceResource[]> {
if (!Array.isArray(payload)) {
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
if (payload.length > 100) {
return mappingFailure("OUTPUT_LIMIT_EXCEEDED");
}
const output: ReferenceResource[] = []; const output: ReferenceResource[] = [];
for (const item of payload) { for (const item of payload) {
const mapped = mapReferenceResourcePayload(item); const mapped = mapReferenceResourcePayload(item);
@@ -57,22 +49,58 @@ export function mapReferenceResourceListPayload(
return mappingSuccess(Object.freeze(output)); return mappingSuccess(Object.freeze(output));
} }
function isReferenceResourceDto(value: unknown): value is ReferenceResourceDto {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const dto = value as Record<string, unknown>;
return (
typeof dto.id === "string" &&
typeof dto.name === "string" &&
(dto.createdAt === undefined || typeof dto.createdAt === "string")
);
}
function mapUnknownReferenceResourcePayload(
value: unknown,
): MappingResult<ReferenceResource> {
return isReferenceResourceDto(value)
? mapReferenceResourcePayload(value)
: mappingFailure("MAPPING_INVARIANT_REJECTED");
}
function mapUnknownReferenceResourceListPayload(
value: unknown,
): MappingResult<readonly ReferenceResource[]> {
if (
!Array.isArray(value) ||
value.length > 100 ||
!value.every(isReferenceResourceDto)
) {
return mappingFailure(
Array.isArray(value) && value.length > 100
? "OUTPUT_LIMIT_EXCEEDED"
: "MAPPING_INVARIANT_REJECTED",
);
}
return mapReferenceResourceListPayload(value);
}
/** /**
* Registry-facing compatibility projection. Feature adapters should prefer the * Registry-facing compatibility projection. The registry is intentionally
* operation-specific mapper functions above so their result type is exact. * untyped, so its adapter performs the defensive shape check. Feature HTTP
* bindings use the typed operation-specific mappers above.
*/ */
export function mapReferenceOperation( export function mapReferenceOperation(
operationId: string, operationId: string,
payload: unknown, payload: unknown,
): MappingResult<ReferenceResource | readonly ReferenceResource[]> { ): MappingResult<ReferenceResource | readonly ReferenceResource[]> {
if (operationId === "LIST_REFERENCE_RESOURCES") { if (operationId === "LIST_REFERENCE_RESOURCES") {
return mapReferenceResourceListPayload(payload); return mapUnknownReferenceResourceListPayload(payload);
} }
if ( if (
operationId === "CREATE_REFERENCE_RESOURCE" || operationId === "CREATE_REFERENCE_RESOURCE" ||
operationId === "GET_REFERENCE_RESOURCE" operationId === "GET_REFERENCE_RESOURCE"
) { ) {
return mapReferenceResourcePayload(payload); return mapUnknownReferenceResourcePayload(payload);
} }
return mappingFailure("MAPPING_INVARIANT_REJECTED"); return mappingFailure("MAPPING_INVARIANT_REJECTED");
} }
@@ -85,7 +113,7 @@ export const REFERENCE_BOUNDARY_MAPPERS = Object.freeze({
outputContractId: "ReferenceResourceList", outputContractId: "ReferenceResourceList",
owner: "feature-frontend-reference-feature-vertical-slice", owner: "feature-frontend-reference-feature-vertical-slice",
maxOutputItems: 100, maxOutputItems: 100,
map: mapReferenceResourceListPayload, map: mapUnknownReferenceResourceListPayload,
}), }),
ReferenceResourceMapper: Object.freeze({ ReferenceResourceMapper: Object.freeze({
mapperId: "ReferenceResourceMapper", mapperId: "ReferenceResourceMapper",
@@ -94,7 +122,7 @@ export const REFERENCE_BOUNDARY_MAPPERS = Object.freeze({
outputContractId: "ReferenceResource", outputContractId: "ReferenceResource",
owner: "feature-frontend-reference-feature-vertical-slice", owner: "feature-frontend-reference-feature-vertical-slice",
maxOutputItems: 1, maxOutputItems: 1,
map: mapReferenceResourcePayload, map: mapUnknownReferenceResourcePayload,
}), }),
} satisfies Readonly<Record<string, InstalledBoundaryMapper>>); } satisfies Readonly<Record<string, InstalledBoundaryMapper>>);
@@ -13,7 +13,7 @@ export const referenceResourceListQuerySchema = z
: Array.isArray(value) : Array.isArray(value)
? value ? value
: [value], : [value],
z.array(z.string().trim().min(1)), z.array(z.string().trim().min(1)).readonly(),
) )
.optional(), .optional(),
}) })
@@ -25,7 +25,7 @@ export const referenceResourceParamsSchema = z
}) })
.strict(); .strict();
const referenceResourceDtoSchema = z export const referenceResourceDtoSchema = z
.object({ .object({
id: z.string().min(1).max(120), id: z.string().min(1).max(120),
name: z.string().min(1).max(240), name: z.string().min(1).max(240),
@@ -33,6 +33,28 @@ const referenceResourceDtoSchema = z
}) })
.strip(); .strip();
export type ReferenceResourceDto = z.output<
typeof referenceResourceDtoSchema
>;
export const referenceProblemSchema = z
.object({
type: z.string().min(1).max(512),
title: z.string().min(1).max(240),
status: z.int().min(100).max(599),
code: z.string().min(1).max(120).optional(),
})
.strip();
export type ReferenceProblem = z.output<typeof referenceProblemSchema>;
export const createReferenceResourceCommandSchema = z
.object({
name: z.string().trim().min(1).max(120),
note: z.string().trim().max(500).optional(),
})
.strict();
const payloadSchemas = { const payloadSchemas = {
ReferenceResourceListPayload: z.array(referenceResourceDtoSchema).max(100), ReferenceResourceListPayload: z.array(referenceResourceDtoSchema).max(100),
ReferenceResourcePayload: referenceResourceDtoSchema, ReferenceResourcePayload: referenceResourceDtoSchema,
@@ -42,12 +64,7 @@ const requestSchemas = {
ReferenceResourceParams: referenceResourceParamsSchema, ReferenceResourceParams: referenceResourceParamsSchema,
ReferenceResourceListQuery: referenceResourceListQuerySchema, ReferenceResourceListQuery: referenceResourceListQuerySchema,
NoRequest: z.object({}).strict(), NoRequest: z.object({}).strict(),
CreateReferenceResourceCommand: z CreateReferenceResourceCommand: createReferenceResourceCommandSchema,
.object({
name: z.string().trim().min(1).max(120),
note: z.string().trim().max(500).optional(),
})
.strict(),
} satisfies Record<string, z.ZodType>; } satisfies Record<string, z.ZodType>;
function project(result: z.ZodSafeParseResult<unknown>) { function project(result: z.ZodSafeParseResult<unknown>) {
@@ -18,13 +18,13 @@ function databaseName(_prefix: string): string {
return `ca-idb-v1:a${token.slice(0, 31)}.n${token.slice(1)}.p${token.split("").reverse().join("").slice(0, 31)}`; return `ca-idb-v1:a${token.slice(0, 31)}.n${token.slice(1)}.p${token.split("").reverse().join("").slice(0, 31)}`;
} }
async function installLifecycleRuntime( async function startLifecycleRuntime(
page: Page, page: Page,
name: string, name: string,
targetVersion: 1 | 2, targetVersion: 1 | 2,
blockedTimeoutMs = 1_000, blockedTimeoutMs = 1_000,
) { ): Promise<void> {
return await page.evaluate( await page.evaluate(
async ({ async ({
currentDatabaseName, currentDatabaseName,
currentTargetVersion, currentTargetVersion,
@@ -173,8 +173,12 @@ async function installLifecycleRuntime(
__indexedDbLifecycleRuntime?: unknown; __indexedDbLifecycleRuntime?: unknown;
} }
).__indexedDbLifecycleRuntime = runtime; ).__indexedDbLifecycleRuntime = runtime;
const opened = await runtime.open(); const opening = runtime.open();
return { opened, status: runtime.getStatus() }; (
globalThis as unknown as {
__indexedDbLifecycleOpening?: Promise<unknown>;
}
).__indexedDbLifecycleOpening = opening;
}, },
{ {
currentDatabaseName: name, currentDatabaseName: name,
@@ -184,6 +188,35 @@ async function installLifecycleRuntime(
); );
} }
async function settleLifecycleRuntime(page: Page): Promise<unknown> {
return await page.evaluate(async () => {
const state = globalThis as unknown as {
__indexedDbLifecycleRuntime?: {
getStatus(): unknown;
};
__indexedDbLifecycleOpening?: Promise<unknown>;
};
if (!state.__indexedDbLifecycleRuntime || !state.__indexedDbLifecycleOpening) {
throw new Error("IndexedDB lifecycle runtime is not installed.");
}
const opened = await state.__indexedDbLifecycleOpening;
return {
opened,
status: state.__indexedDbLifecycleRuntime.getStatus(),
};
});
}
async function installLifecycleRuntime(
page: Page,
name: string,
targetVersion: 1 | 2,
blockedTimeoutMs = 1_000,
): Promise<unknown> {
await startLifecycleRuntime(page, name, targetVersion, blockedTimeoutMs);
return await settleLifecycleRuntime(page);
}
async function lifecycleStatus(page: Page): Promise<unknown> { async function lifecycleStatus(page: Page): Promise<unknown> {
return await page.evaluate(() => { return await page.evaluate(() => {
const runtime = ( const runtime = (
@@ -977,12 +1010,16 @@ test("fails a blocked v2 upgrade closed, then closes its late connection after t
); );
expect(blocker).toBe("OPEN"); expect(blocker).toBe("OPEN");
const blocked = await installLifecycleRuntime( await startLifecycleRuntime(page, name, 2, 500);
page, await expect
name, .poll(async () => await lifecycleStatus(page))
2, .toEqual({
25, kind: "BLOCKED",
); currentVersion: 1,
targetVersion: 2,
});
const blocked = await settleLifecycleRuntime(page);
expect(blocked).toMatchObject({ expect(blocked).toMatchObject({
opened: { opened: {
ok: false, ok: false,
@@ -992,9 +1029,8 @@ test("fails a blocked v2 upgrade closed, then closes its late connection after t
}, },
}, },
status: { status: {
kind: "BLOCKED", kind: "CLOSED",
currentVersion: 1, reason: "NOT_OPENED",
targetVersion: 2,
}, },
}); });
@@ -0,0 +1,244 @@
import type {
IndexedDbRuntimeDependencies,
} from "../../src/adapters/storage/indexeddb/indexeddb-types.ts";
import type { LocalDraft } from "../../src/features/local-draft-feature/domain/local-draft.ts";
import {
expect,
test,
} from "../support/browser/strict-browser-test.ts";
test("composes the local-draft feature over the real browser IndexedDB runtime", async ({
page,
}) => {
await page.route("**/favicon.ico", (route) =>
route.fulfill({ status: 204 }),
);
await page.goto("/config.json");
const result = await page.evaluate(async () => {
const indexedDbModulePath =
"/src/adapters/storage/indexeddb/index.ts";
const contributionModulePath =
"/src/features/feature-adapter-contribution.ts";
const localDraftModulePath =
"/src/features/local-draft-feature/adapters/create-local-draft-feature-input.ts";
const {
createIndexedDbRuntime,
indexedDbPhysicalDatabaseName,
} = (await import(
/* @vite-ignore */ indexedDbModulePath
)) as typeof import("../../src/adapters/storage/indexeddb/index.ts");
const {
composeFeatureAdapterInputs,
createIndexedDbRepositoryProvider,
} = (await import(
/* @vite-ignore */ contributionModulePath
)) as typeof import("../../src/features/feature-adapter-contribution.ts");
const {
LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION,
} = (await import(
/* @vite-ignore */ localDraftModulePath
)) as typeof import("../../src/features/local-draft-feature/adapters/create-local-draft-feature-input.ts");
const suffix = crypto.randomUUID().replaceAll("-", "");
const scope = Object.freeze({
authorityToken: `a${suffix.slice(0, 31)}`,
namespaceToken: `n${suffix.slice(1, 32)}`,
partitionToken: `p${[...suffix].reverse().join("").slice(0, 31)}`,
accountScope: "ORIGIN_SHARED" as const,
});
const storagePolicy = Object.freeze({
owner: "local-draft-feature",
namespace: "local-draft",
classification: "INTERNAL" as const,
authority: "LOCAL_FIRST" as const,
accountScope: "ORIGIN_SHARED" as const,
retention: Object.freeze({ kind: "EXPLICIT_DELETE" as const }),
softBudgetBytes: 1_000_000,
hardBudgetBytes: 2_000_000,
evictionPriority: "USER_AUTHORED" as const,
logoutAction: "KEEP_ORIGIN_SHARED" as const,
accountDeletionAction: "KEEP_ORIGIN_SHARED" as const,
pressureAction: "RETAIN" as const,
unavailableFallback: "READ_ONLY" as const,
});
const config: IndexedDbRuntimeDependencies<
LocalDraft,
LocalDraft,
never
> = {
scope,
storagePolicy,
schemaVersion: 1,
recordStore: "records",
governanceStore: "governance",
retentionStore: "retention",
retentionEligibilityIndex: "by-eligibility",
lifecycleMetadataStores: [],
idempotencyStore: "receipts",
idempotencyExpiryIndex: "by-expiry",
receiptRetentionMs: 60_000,
maxIdempotencyReceipts: 100,
migrations: [
{
id: "local-draft-schema-v1",
fromVersion: 0,
toVersion: 1,
operations: [
{
kind: "CREATE_STORE",
name: "governance",
keyPath: "bindingKey",
},
{
kind: "CREATE_STORE",
name: "retention",
keyPath: "recordKey",
indexes: [
{
name: "by-eligibility",
keyPath: "eligibleAtEpochMs",
},
],
},
{
kind: "CREATE_STORE",
name: "records",
keyPath: "key",
},
{
kind: "CREATE_STORE",
name: "receipts",
keyPath: "idempotencyKey",
indexes: [
{
name: "by-expiry",
keyPath: "expiresAtEpochMs",
},
],
},
],
},
],
codec: {
currentVersion: 1,
encode: (value) => ({ ok: true, value }),
measureStoredBytes: (value) =>
new TextEncoder().encode(JSON.stringify(value)).byteLength,
decode: (version, value) => {
if (
version !== 1 ||
value === null ||
typeof value !== "object"
) {
return { ok: false };
}
const candidate = value as Partial<LocalDraft>;
return typeof candidate.draftId === "string" &&
typeof candidate.title === "string" &&
typeof candidate.body === "string"
? { ok: true, value: candidate as LocalDraft }
: { ok: false };
},
fingerprint: async (value) => {
const bytes = new TextEncoder().encode(
JSON.stringify([value.draftId, value.title, value.body]),
);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(
new Uint8Array(digest),
(byte) => byte.toString(16).padStart(2, "0"),
).join("");
},
},
queryPolicy: {
plan: () => ({ limit: 1 }),
},
authorizeLifecycle: () => ({
authorized: true,
proofToken: "localdraftauthorityproof_001",
}),
};
const runtime = createIndexedDbRuntime(config);
const databaseName = indexedDbPhysicalDatabaseName(scope);
const opened = await runtime.open();
try {
const indexedDb = createIndexedDbRepositoryProvider(
Object.freeze({ "local-draft": runtime }),
);
const composed = composeFeatureAdapterInputs(
Object.freeze([LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION] as const),
Object.freeze(["local-draft"]),
Object.freeze({ indexedDb }),
);
const localDraft = composed["local-draft"];
if (!localDraft) {
throw new Error("local-draft feature input was not composed");
}
const draft = Object.freeze({
draftId: "draft-1",
title: "Architecture notes",
body: "Stored through the reusable IndexedDB capability.",
});
const saved = await localDraft.saveDraft({
draft,
expectedRevision: null,
idempotencyKey: "local-draft-create-1",
});
const found = await localDraft.findDraft(draft.draftId);
const removed = await localDraft.removeDraft({
draftId: draft.draftId,
expectedRevision: saved.ok ? saved.value.revision : 1,
idempotencyKey: "local-draft-remove-1",
});
const afterRemove = await localDraft.findDraft(draft.draftId);
return {
opened,
saved,
found,
removed,
afterRemove,
composedFeatureIds: Object.keys(composed),
runtimeStatus: runtime.getStatus(),
};
} finally {
runtime.close();
await new Promise<void>((resolve, reject) => {
const request = indexedDB.deleteDatabase(databaseName);
request.onsuccess = () => resolve();
request.onerror = () =>
reject(request.error ?? new Error("IndexedDB cleanup failed"));
request.onblocked = () =>
reject(new Error("IndexedDB cleanup was blocked"));
});
}
});
expect(result.opened).toEqual({ ok: true, value: undefined });
expect(result.composedFeatureIds).toEqual(["local-draft"]);
expect(result.saved).toEqual({ ok: true, value: { revision: 1 } });
expect(result.found).toMatchObject({
ok: true,
value: {
draft: {
draftId: "draft-1",
title: "Architecture notes",
body: "Stored through the reusable IndexedDB capability.",
},
revision: 1,
},
});
expect(result.removed).toEqual({ ok: true, value: undefined });
expect(result.afterRemove).toEqual({ ok: true, value: null });
expect(result.runtimeStatus).toMatchObject({
kind: "READY",
schemaVersion: 1,
});
});
@@ -1,29 +1,48 @@
import { expect, test } from "../support/browser/strict-browser-test.ts"; import { expect, test } from "../support/browser/strict-browser-test.ts";
test("executes the storage durability adapter against the real browser StorageManager", async ({ test("executes the storage durability adapter against the real browser StorageManager capability", async ({
page, page,
}) => { }) => {
await page.goto("/"); await page.goto("/");
const result = await page.evaluate(async () => { const observation = await page.evaluate(async () => {
const modulePath = const modulePath =
"/src/adapters/browser-file-storage/storage-manager-adapter.ts"; "/src/adapters/browser-file-storage/storage-manager-adapter.ts";
const { createStorageDurabilityAdapter } = await import( const { createStorageDurabilityAdapter } = await import(
/* @vite-ignore */ modulePath /* @vite-ignore */ modulePath
); );
const hasStorageManager = navigator.storage !== undefined;
const adapter = createStorageDurabilityAdapter(navigator.storage); const adapter = createStorageDurabilityAdapter(navigator.storage);
return adapter.inspect(); return {
hasStorageManager,
result: await adapter.inspect(),
};
}); });
expect(result.ok).toBe(true); if (!observation.hasStorageManager) {
if (result.ok) { expect(observation.result).toEqual({
ok: false,
error: {
code: "UNSUPPORTED",
operation: "STORAGE_ESTIMATE",
retryable: false,
recovery: "ONLINE_ONLY",
},
});
return;
}
expect(observation.result.ok).toBe(true);
if (observation.result.ok) {
expect(["UNKNOWN", "NORMAL", "PRESSURE", "CRITICAL"]).toContain( expect(["UNKNOWN", "NORMAL", "PRESSURE", "CRITICAL"]).toContain(
result.value.pressure, observation.result.value.pressure,
); );
expect( expect(
result.value.usageBytes === null || result.value.usageBytes >= 0, observation.result.value.usageBytes === null ||
observation.result.value.usageBytes >= 0,
).toBe(true); ).toBe(true);
expect( expect(
result.value.quotaBytes === null || result.value.quotaBytes >= 0, observation.result.value.quotaBytes === null ||
observation.result.value.quotaBytes >= 0,
).toBe(true); ).toBe(true);
} }
}); });
+3 -1
View File
@@ -71,7 +71,9 @@ describe("generic application router", () => {
await screen.findByRole("heading", { name: "UI 구성요소", level: 1 }), await screen.findByRole("heading", { name: "UI 구성요소", level: 1 }),
).toBeVisible(); ).toBeVisible();
expect(window.location.pathname).toBe("/examples/ui"); expect(window.location.pathname).toBe("/examples/ui");
expect(document.title).toBe("UI 구성요소 · Frontend Skeleton"); await waitFor(() =>
expect(document.title).toBe("UI 구성요소 · Frontend Skeleton"),
);
await waitFor(() => await waitFor(() =>
expect( expect(
screen.getByRole("heading", { name: "UI 구성요소", level: 1 }), screen.getByRole("heading", { name: "UI 구성요소", level: 1 }),
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import type {
IndexedDbRepositoryPort,
} from "../../../src/application/ports/browser-file-storage/index.ts";
import type {
InstalledHttpOperationExecutor,
} from "../../../src/adapters/http/index.ts";
import {
composeFeatureAdapterInputs,
defineFeatureAdapterContribution,
type IndexedDbRepositoryProvider,
} from "../../../src/features/feature-adapter-contribution.ts";
import {
LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION,
} from "../../../src/features/local-draft-feature/adapters/create-local-draft-feature-input.ts";
import type { LocalDraft } from "../../../src/features/local-draft-feature/domain/local-draft.ts";
type TestHttpFeatureInput = Readonly<{
ping(): Promise<"pong">;
}>;
declare module "../../../src/application/ports/in/application-api.ts" {
interface ApplicationFeatureInputs {
"test-http-feature": TestHttpFeatureInput;
}
}
const TEST_HTTP_FEATURE_ADAPTER_CONTRIBUTION =
defineFeatureAdapterContribution({
featureId: "test-http-feature",
needs: ["http"] as const,
createInput() {
return Object.freeze({
featureId: "test-http-feature" as const,
input: Object.freeze({
async ping() {
return "pong" as const;
},
}),
});
},
});
function localDraftRepository() {
let stored: Readonly<{ value: LocalDraft; revision: number }> | null = null;
const repository = Object.freeze({
async compareAndSwap(input: Readonly<{ value: LocalDraft }>) {
stored = Object.freeze({ value: input.value, revision: 1 });
return Object.freeze({
ok: true as const,
value: Object.freeze({ revision: 1 }),
});
},
async read() {
return Object.freeze({ ok: true as const, value: stored });
},
async remove() {
stored = null;
return Object.freeze({
ok: true as const,
value: Object.freeze({ revision: 2 }),
});
},
}) as unknown as IndexedDbRepositoryPort<LocalDraft, never>;
return repository;
}
describe("feature adapter composition", () => {
it("composes HTTP-only and IndexedDB-only concrete contributions together", async () => {
const repository = localDraftRepository();
const get = ((repositoryId: string) => {
expect(repositoryId).toBe("local-draft");
return repository;
}) as unknown as IndexedDbRepositoryProvider["get"];
const indexedDb = Object.freeze({ get }) satisfies IndexedDbRepositoryProvider;
const http = Object.freeze({
execute: async () => {
throw new Error("HTTP should not execute during composition");
},
}) as unknown as InstalledHttpOperationExecutor;
const inputs = composeFeatureAdapterInputs(
Object.freeze([
TEST_HTTP_FEATURE_ADAPTER_CONTRIBUTION,
LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION,
] as const),
Object.freeze(["test-http-feature", "local-draft"]),
Object.freeze({ http, indexedDb }),
);
expect(Object.keys(inputs).sort()).toEqual([
"local-draft",
"test-http-feature",
]);
await expect(inputs["test-http-feature"]?.ping()).resolves.toBe("pong");
const localDraft = inputs["local-draft"];
if (!localDraft) throw new Error("local-draft input was not composed");
const draft = Object.freeze({
draftId: "draft-1",
title: "Local draft",
body: "Body",
});
await expect(
localDraft.saveDraft({
draft,
expectedRevision: null,
idempotencyKey: "save-draft-1",
}),
).resolves.toEqual({ ok: true, value: { revision: 1 } });
await expect(localDraft.findDraft("draft-1")).resolves.toEqual({
ok: true,
value: { draft, revision: 1 },
});
});
});
@@ -3,57 +3,129 @@ import { describe, expect, it, vi } from "vitest";
import { import {
createFeatureHttpBinding, createFeatureHttpBinding,
defineFeatureHttpOperation, defineFeatureHttpOperation,
type HttpExecutionOutcome,
type InstalledHttpOperationExecutor, type InstalledHttpOperationExecutor,
} from "../../../src/adapters/http/index.ts"; } from "../../../src/adapters/http/index.ts";
import { import {
mappingFailure, mappingFailure,
mappingSuccess, mappingSuccess,
} from "../../../src/contracts/boundary-mapper.ts"; } from "../../../src/contracts/boundary-mapper.ts";
import type {
InstalledHttpContract,
RuntimeValidator,
} from "../../../src/contracts/external-contract-runtime.ts";
type ResourceInput = Readonly<{ resourceId: string }>;
type ResourceWire = Readonly<{ id: string; title: string }>;
type ResourceProblem = Readonly<{ code?: string }>;
type Resource = Readonly<{ id: string; title: string }>; type Resource = Readonly<{ id: string; title: string }>;
const OPERATIONS = Object.freeze({ function validator<T>(schemaId: string): RuntimeValidator<T> {
LOAD_RESOURCE: defineFeatureHttpOperation< return Object.freeze({
Readonly<{ resourceId: string }>, schemaId,
Resource safeParse(value: unknown) {
>({ return Object.freeze({
success: true as const,
data: value as T,
});
},
});
}
const LOAD_RESOURCE_CONTRACT = Object.freeze({
contract: Object.freeze({
operationId: "LOAD_RESOURCE", operationId: "LOAD_RESOURCE",
method: "GET" as const,
pathTemplate: "/resources/{resourceId}",
inputValidator: validator<ResourceInput>("ResourceInput"),
outputValidator: validator<ResourceWire>("ResourceWire"),
problemValidator: validator<ResourceProblem>("ResourceProblem"),
acceptedStatuses: Object.freeze([200]),
emptyBodyStatuses: Object.freeze([]),
retrySemantics: "SAFE" as const,
requestBody: "NONE" as const,
responseBody: "REQUIRED_JSON" as const,
commandRecovery: null,
commandEffect: null,
projectRequest(input: ResourceInput) {
return Object.freeze({
pathValues: Object.freeze({ resourceId: input.resourceId }),
queryEntries: Object.freeze([]),
body: null,
});
},
}),
frontend: Object.freeze({
policyId: "TEST_LOAD_RESOURCE",
requestByteLimit: 0,
responseByteLimit: 8_192,
totalDeadlineMs: 1_000,
retryBudget: 0 as const,
authProfileId: "TEST",
diagnosticsOperation: "test.load-resource",
}),
}) satisfies InstalledHttpContract<ResourceInput, ResourceWire, ResourceProblem>;
const OPERATIONS = Object.freeze({
LOAD_RESOURCE: defineFeatureHttpOperation({
contract: LOAD_RESOURCE_CONTRACT,
routeId: "RESOURCE_DETAIL", routeId: "RESOURCE_DETAIL",
mapSuccess(value) { mapSuccess(value) {
if ( if (value.title.length === 0) {
!value ||
typeof value !== "object" ||
typeof (value as Record<string, unknown>).id !== "string" ||
typeof (value as Record<string, unknown>).title !== "string"
) {
return mappingFailure("MAPPING_INVARIANT_REJECTED"); return mappingFailure("MAPPING_INVARIANT_REJECTED");
} }
const candidate = value as Readonly<{ id: string; title: string }>;
return mappingSuccess( return mappingSuccess(
Object.freeze({ id: candidate.id, title: candidate.title }), Object.freeze({ id: value.id, title: value.title }),
); );
}, },
mapProblem(problem, metadata) {
if (metadata.status !== 409) return undefined;
return Object.freeze({
kind: "CONFLICT" as const,
code: problem.code ?? "RESOURCE_CONFLICT",
});
},
}), }),
} as const); } as const);
function scriptedExecutor<WireOutput, Problem>(
outcome: HttpExecutionOutcome<WireOutput, Problem>,
) {
const calls: Array<
Readonly<{
contract: unknown;
input: unknown;
context: unknown;
}>
> = [];
const implementation = async (
contract: unknown,
input: unknown,
context: unknown,
) => {
calls.push(Object.freeze({ contract, input, context }));
return outcome;
};
const execute = vi.fn(implementation) as unknown as
InstalledHttpOperationExecutor["execute"];
return {
executor: Object.freeze({ execute }) satisfies InstalledHttpOperationExecutor,
calls,
execute,
};
}
describe("feature HTTP binding", () => { describe("feature HTTP binding", () => {
it("keeps typed feature input while platform owns route/context execution", async () => { it("keeps contract-derived input while platform owns route/context execution", async () => {
const execute = vi.fn<InstalledHttpOperationExecutor["execute"]>( const scripted = scriptedExecutor<ResourceWire, ResourceProblem>(
async (_operationId, input, context) => { Object.freeze({
expect(input).toEqual({ resourceId: "resource-1" }); kind: "SUCCESS" as const,
expect(context.routeId).toBe("RESOURCE_DETAIL"); value: Object.freeze({ id: "resource-1", title: "Reference" }),
return Object.freeze({ metadata: Object.freeze({ status: 200 }),
kind: "SUCCESS" as const, effect: "NOT_APPLICABLE" as const,
value: Object.freeze({ id: "resource-1", title: "Reference" }), }),
metadata: Object.freeze({ status: 200 }),
effect: "NOT_APPLICABLE" as const,
});
},
);
const binding = createFeatureHttpBinding(
Object.freeze({ execute }),
OPERATIONS,
); );
const binding = createFeatureHttpBinding(scripted.executor, OPERATIONS);
const result = await binding.execute("LOAD_RESOURCE", { const result = await binding.execute("LOAD_RESOURCE", {
resourceId: "resource-1", resourceId: "resource-1",
@@ -63,23 +135,27 @@ describe("feature HTTP binding", () => {
ok: true, ok: true,
value: { id: "resource-1", title: "Reference" }, value: { id: "resource-1", title: "Reference" },
}); });
expect(execute).toHaveBeenCalledTimes(1); expect(scripted.calls).toEqual([
{
contract: LOAD_RESOURCE_CONTRACT,
input: { resourceId: "resource-1" },
context: { routeId: "RESOURCE_DETAIL" },
},
]);
}); });
it("normalizes transport failure before it crosses the feature gateway", async () => { it("normalizes transport failure before it crosses the feature gateway", async () => {
const executor: InstalledHttpOperationExecutor = Object.freeze({ const scripted = scriptedExecutor<ResourceWire, ResourceProblem>(
async execute() { Object.freeze({
return Object.freeze({ kind: "TRANSPORT_FAILURE" as const,
kind: "TRANSPORT_FAILURE" as const, failure: Object.freeze({
failure: Object.freeze({ kind: "TIMEOUT" as const,
kind: "TIMEOUT" as const, retryable: true,
retryable: true, }),
}), effect: "NOT_STARTED" as const,
effect: "NOT_STARTED" as const, }),
}); );
}, const binding = createFeatureHttpBinding(scripted.executor, OPERATIONS);
});
const binding = createFeatureHttpBinding(executor, OPERATIONS);
const result = await binding.execute("LOAD_RESOURCE", { const result = await binding.execute("LOAD_RESOURCE", {
resourceId: "resource-1", resourceId: "resource-1",
@@ -92,18 +168,16 @@ describe("feature HTTP binding", () => {
expect(result.error.effect).toBe("NOT_STARTED"); expect(result.error.effect).toBe("NOT_STARTED");
}); });
it("turns feature mapper rejection into the shared mapping failure", async () => { it("turns a domain mapper rejection into the shared mapping failure", async () => {
const executor: InstalledHttpOperationExecutor = Object.freeze({ const scripted = scriptedExecutor<ResourceWire, ResourceProblem>(
async execute() { Object.freeze({
return Object.freeze({ kind: "SUCCESS" as const,
kind: "SUCCESS" as const, value: Object.freeze({ id: "resource-1", title: "" }),
value: Object.freeze({ unexpected: true }), metadata: Object.freeze({ status: 200 }),
metadata: Object.freeze({ status: 200 }), effect: "NOT_APPLICABLE" as const,
effect: "NOT_APPLICABLE" as const, }),
}); );
}, const binding = createFeatureHttpBinding(scripted.executor, OPERATIONS);
});
const binding = createFeatureHttpBinding(executor, OPERATIONS);
const result = await binding.execute("LOAD_RESOURCE", { const result = await binding.execute("LOAD_RESOURCE", {
resourceId: "resource-1", resourceId: "resource-1",
@@ -114,4 +188,29 @@ describe("feature HTTP binding", () => {
expect(result.error.kind).toBe("MAPPING_CONTRACT_VIOLATION"); expect(result.error.kind).toBe("MAPPING_CONTRACT_VIOLATION");
expect(result.error.code).toBe("MAPPING_INVARIANT_REJECTED"); expect(result.error.code).toBe("MAPPING_INVARIANT_REJECTED");
}); });
it("lets the feature interpret a typed business problem", async () => {
const scripted = scriptedExecutor<ResourceWire, ResourceProblem>(
Object.freeze({
kind: "PROBLEM" as const,
problem: Object.freeze({ code: "RESOURCE_NAME_EXISTS" }),
metadata: Object.freeze({ status: 409 }),
effect: "NOT_APPLIED" as const,
}),
);
const binding = createFeatureHttpBinding(scripted.executor, OPERATIONS);
const result = await binding.execute("LOAD_RESOURCE", {
resourceId: "resource-1",
});
expect(result).toMatchObject({
ok: false,
error: {
kind: "CONFLICT",
code: "RESOURCE_NAME_EXISTS",
httpStatus: 409,
},
});
});
}); });
@@ -32,7 +32,7 @@ describe("reference feature boundary contracts", () => {
effect: "MAYBE_APPLIED" as const, effect: "MAYBE_APPLIED" as const,
})); }));
const installed = createReferenceFeatureInstalledInput({ const installed = createReferenceFeatureInstalledInput({
contractOperations: { execute }, http: { execute },
}); });
await expect( await expect(
@@ -3,7 +3,6 @@ import { describe, expect, it, vi } from "vitest";
import { createContractHttpExecutor } from "../../../src/adapters/http/index.ts"; import { createContractHttpExecutor } from "../../../src/adapters/http/index.ts";
import { createHttpObservationProjector } from "../../../src/bootstrap/runtime-adapters.ts"; import { createHttpObservationProjector } from "../../../src/bootstrap/runtime-adapters.ts";
import { createReferenceFeatureInstalledInput } from "../../../src/features/reference-feature/adapters/create-reference-feature-input.ts"; import { createReferenceFeatureInstalledInput } from "../../../src/features/reference-feature/adapters/create-reference-feature-input.ts";
import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "../../../src/features/reference-feature/contracts/reference-feature-contract-contribution.ts";
function scopeSnapshot() { function scopeSnapshot() {
return Object.freeze({ return Object.freeze({
@@ -34,17 +33,9 @@ describe("reference feature HTTP diagnostics", () => {
telemetry: { emit: vi.fn() }, telemetry: { emit: vi.fn() },
}), }),
}); });
const operations = new Map(
REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION.http.map((operation) => [
operation.contract.operationId,
operation,
]),
);
const installed = createReferenceFeatureInstalledInput({ const installed = createReferenceFeatureInstalledInput({
contractOperations: Object.freeze({ http: Object.freeze({
async execute(operationId, input, context) { async execute(operation, input, context) {
const operation = operations.get(operationId);
if (!operation) throw new Error("Unregistered reference operation");
return contractHttp.execute(operation, input, { return contractHttp.execute(operation, input, {
routeId: context.routeId, routeId: context.routeId,
scope: scopeSnapshot(), scope: scopeSnapshot(),
@@ -0,0 +1,135 @@
import { describe, expect, it } from "vitest";
import {
createFeatureHttpBinding,
type InstalledHttpOperationExecutor,
} from "../../../src/adapters/http/index.ts";
import {
createReferenceHttpGateway,
REFERENCE_HTTP_OPERATIONS,
} from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts";
function executorReturning(
outcome: Readonly<Record<string, unknown>>,
): InstalledHttpOperationExecutor {
const execute = (async () => outcome) as unknown as
InstalledHttpOperationExecutor["execute"];
return Object.freeze({ execute });
}
describe("reference HTTP business problem mapping", () => {
it("maps a typed 409 reference problem to the feature-owned business code", async () => {
const executor = executorReturning(
Object.freeze({
kind: "PROBLEM",
problem: Object.freeze({
type: "https://example.test/problems/reference-conflict",
title: "Reference conflict",
status: 409,
code: "REFERENCE_NAME_ALREADY_EXISTS",
}),
metadata: Object.freeze({ status: 409 }),
effect: "NOT_APPLIED",
}),
);
const gateway = createReferenceHttpGateway(
createFeatureHttpBinding(executor, REFERENCE_HTTP_OPERATIONS),
);
await expect(
gateway.create({ name: "duplicate" }),
).resolves.toMatchObject({
ok: false,
error: {
kind: "CONFLICT",
code: "REFERENCE_NAME_ALREADY_EXISTS",
httpStatus: 409,
},
});
});
it.each([
{
status: 409,
code: undefined,
expectedKind: "CONFLICT",
expectedCode: "REFERENCE_RESOURCE_CONFLICT",
},
{
status: 422,
code: "REFERENCE_NAME_REJECTED",
expectedKind: "VALIDATION_REJECTED",
expectedCode: "REFERENCE_NAME_REJECTED",
},
{
status: 422,
code: undefined,
expectedKind: "VALIDATION_REJECTED",
expectedCode: "REFERENCE_RESOURCE_REJECTED",
},
{
status: 400,
code: "REFERENCE_BAD_REQUEST",
expectedKind: "UNKNOWN_CLIENT_FAILURE",
expectedCode: "CONTRACT_PROBLEM",
},
] as const)(
"projects create problem status $status through the feature-owned mapper/fallback",
async ({ status, code, expectedKind, expectedCode }) => {
const executor = executorReturning(
Object.freeze({
kind: "PROBLEM",
problem: Object.freeze({
type: "https://example.test/problems/reference-" + status,
title: "Reference request rejected",
status,
...(code === undefined ? {} : { code }),
}),
metadata: Object.freeze({ status }),
effect: "NOT_APPLIED",
}),
);
const gateway = createReferenceHttpGateway(
createFeatureHttpBinding(executor, REFERENCE_HTTP_OPERATIONS),
);
await expect(
gateway.create({ name: "rejected" }),
).resolves.toMatchObject({
ok: false,
error: {
kind: expectedKind,
code: expectedCode,
httpStatus: status,
},
});
},
);
it("keeps the platform status fallback when the operation has no problem mapper", async () => {
const executor = executorReturning(
Object.freeze({
kind: "PROBLEM",
problem: Object.freeze({
type: "https://example.test/problems/not-found",
title: "Not found",
status: 404,
}),
metadata: Object.freeze({ status: 404 }),
effect: "NOT_APPLIED",
}),
);
const gateway = createReferenceHttpGateway(
createFeatureHttpBinding(executor, REFERENCE_HTTP_OPERATIONS),
);
await expect(gateway.get("missing")).resolves.toMatchObject({
ok: false,
error: {
kind: "NOT_FOUND",
code: "CONTRACT_PROBLEM",
httpStatus: 404,
},
});
});
});
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import {
type InstalledHttpOperationExecutor,
} from "../../../src/adapters/http/index.ts";
import { createReferenceFeatureInstalledInput } from "../../../src/features/reference-feature/adapters/create-reference-feature-input.ts"; import { createReferenceFeatureInstalledInput } from "../../../src/features/reference-feature/adapters/create-reference-feature-input.ts";
/** /**
@@ -14,22 +17,31 @@ import { createReferenceFeatureInstalledInput } from "../../../src/features/refe
describe("reference feature installed operation executor", () => { describe("reference feature installed operation executor", () => {
it("preserves the feature route id through the installed operation executor", async () => { it("preserves the feature route id through the installed operation executor", async () => {
const seen: Array<Readonly<Record<string, unknown>>> = []; const seen: Array<Readonly<Record<string, unknown>>> = [];
const implementation = async (
contract: Readonly<{ contract: Readonly<{ operationId: string }> }>,
_input: unknown,
context: Readonly<Record<string, unknown>>,
) => {
seen.push(Object.freeze({ ...context }));
const value =
contract.contract.operationId === "LIST_REFERENCE_RESOURCES"
? []
: {
id: "resource-1",
name: "Resource",
};
return Object.freeze({
kind: "SUCCESS" as const,
value,
metadata: Object.freeze({ status: 200 }),
effect: "NOT_APPLICABLE" as const,
});
};
const execute = implementation as unknown as
InstalledHttpOperationExecutor["execute"];
const installed = createReferenceFeatureInstalledInput({ const installed = createReferenceFeatureInstalledInput({
contractOperations: Object.freeze({ http: Object.freeze({ execute }),
async execute(
_operationId: string,
_input: unknown,
context?: Readonly<Record<string, unknown>>,
) {
seen.push(Object.freeze({ ...(context ?? {}) }));
return Object.freeze({
kind: "SUCCESS" as const,
value: [],
metadata: Object.freeze({ status: 200 }),
effect: "NOT_APPLICABLE" as const,
});
},
}),
}); });
await installed.input.listResources({ limit: 20 }); await installed.input.listResources({ limit: 20 });
+1 -1
View File
@@ -35,7 +35,7 @@
"src/application/create-application.ts": { "src/application/create-application.ts": {
"lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 } "lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }
}, },
"src/application/policies/compatibility.ts": { "src/contracts/compatibility.ts": {
"lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 } "lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }
}, },
"src/application/policies/performance-budgets.ts": { "src/application/policies/performance-budgets.ts": {
@@ -0,0 +1,33 @@
import type { InstalledHttpOperationExecutor } from "../../../src/adapters/http/index.ts";
import { composeFeatureAdapterInputs } from "../../../src/features/feature-adapter-contribution.ts";
declare module "../../../src/application/ports/in/application-api.ts" {
interface ApplicationFeatureInputs {
"direct-compose-probe": Readonly<{
ping(): "pong";
}>;
}
}
const malformedRawContribution = Object.freeze({
featureId: "direct-compose-probe" as const,
needs: ["http"] as const,
createInput() {
return Object.freeze({
featureId: "direct-compose-probe" as const,
input: Object.freeze({
ping() {
return "pong" as const;
},
}),
});
},
});
composeFeatureAdapterInputs(
Object.freeze([malformedRawContribution] as const),
Object.freeze(["direct-compose-probe"]),
Object.freeze({
http: null as unknown as InstalledHttpOperationExecutor,
}),
);
@@ -0,0 +1,13 @@
import { defineFeatureAdapterContribution } from "../../../src/features/feature-adapter-contribution.ts";
import { createReferenceFeatureInstalledInput } from "../../../src/features/reference-feature/adapters/create-reference-feature-input.ts";
defineFeatureAdapterContribution({
featureId: "reference-feature",
needs: ["http"] as const,
createInput(context) {
context.indexedDb;
return createReferenceFeatureInstalledInput({
http: context.http,
});
},
});
@@ -0,0 +1,15 @@
import { defineFeatureAdapterContribution } from "../../../src/features/feature-adapter-contribution.ts";
import type {} from "../../../src/features/reference-feature/application/reference-feature-api.ts";
defineFeatureAdapterContribution({
featureId: "reference-feature",
needs: ["http"] as const,
createInput() {
return {
featureId: "reference-feature" as const,
input: {
definitelyNotReferenceFeatureInput: true,
},
};
},
});
+19
View File
@@ -0,0 +1,19 @@
import {
createFeatureHttpBinding,
defineFeatureHttpOperation,
type InstalledHttpOperationExecutor,
} from "../../../src/adapters/http/index.ts";
import { GET_REFERENCE_RESOURCE_CONTRACT } from "../../../src/features/reference-feature/contracts/reference-feature-contract-contribution.ts";
import { mapReferenceResourcePayload } from "../../../src/features/reference-feature/contracts/reference-mapper.ts";
declare const executor: InstalledHttpOperationExecutor;
const operations = Object.freeze({
TOTALLY_WRONG_OPERATION_ID: defineFeatureHttpOperation({
contract: GET_REFERENCE_RESOURCE_CONTRACT,
routeId: "REFERENCE_RESOURCE_DETAIL",
mapSuccess: mapReferenceResourcePayload,
}),
});
createFeatureHttpBinding(executor, operations);
@@ -0,0 +1,7 @@
import type { ReferenceHttpBinding } from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts";
declare const http: ReferenceHttpBinding;
http.execute("GET_REFERENCE_RESOURCE", {
name: "wrong-command-for-get",
});
+15
View File
@@ -0,0 +1,15 @@
import { defineFeatureHttpOperationForRoutes } from "../../../src/adapters/http/index.ts";
import {
type ReferenceFeatureRouteId,
} from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts";
import { GET_REFERENCE_RESOURCE_CONTRACT } from "../../../src/features/reference-feature/contracts/reference-feature-contract-contribution.ts";
import { mapReferenceResourcePayload } from "../../../src/features/reference-feature/contracts/reference-mapper.ts";
const defineReferenceOperation =
defineFeatureHttpOperationForRoutes<ReferenceFeatureRouteId>();
defineReferenceOperation({
contract: GET_REFERENCE_RESOURCE_CONTRACT,
routeId: "TOTALLY_WRONG_ROUTE",
mapSuccess: mapReferenceResourcePayload,
});
+13
View File
@@ -0,0 +1,13 @@
import { defineFeatureHttpOperation } from "../../../src/adapters/http/index.ts";
import { GET_REFERENCE_RESOURCE_CONTRACT } from "../../../src/features/reference-feature/contracts/reference-feature-contract-contribution.ts";
defineFeatureHttpOperation({
contract: GET_REFERENCE_RESOURCE_CONTRACT,
routeId: "REFERENCE_RESOURCE_DETAIL",
mapSuccess(value: Readonly<{ unrelatedWireField: number }>) {
return {
ok: true as const,
value,
};
},
});
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-application-input.ts"
]
}
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-application-output.ts"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-async-overlay.ts"
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"types": [
"node",
"vite/client"
],
"isolatedModules": false
}
}
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-diagnostics-port.ts"
]
}
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-direct-feature-composition.ts"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-failure-kind.ts"
]
}
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-feature-capability-selection.ts"
]
}
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-feature-contribution-input.ts"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-feature-input.ts"
]
}
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-http-operation-id.ts"
]
}
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-http-operation-input.ts"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-http-route-id.ts"
]
}
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-http-wire-mapper.ts"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-icon-button.tsx"
]
}
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-image-cdn-resolve-signal.ts"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-message-key.ts"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-message-params.ts"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-page-action.tsx"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-port-call.ts"
]
}
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-port-implementation.ts"
]
}
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-reference-operation.ts"
]
}
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-result-narrowing.ts"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"files": [
"./invalid-route-runtime.ts"
]
}
@@ -25,6 +25,13 @@ import { afterEach, describe, expect, it } from "vitest";
* longer. Raising the global default instead would hide a genuinely hung test. * longer. Raising the global default instead would hide a genuinely hung test.
*/ */
const PROCESS_HEAVY_TIMEOUT_MS = 30_000; const PROCESS_HEAVY_TIMEOUT_MS = 30_000;
/**
* Test watchdog only. This is not a provider protocol deadline. Child-process
* scheduling can be delayed when the repository suite is under load, so the
* watchdog must not encode a one-second protocol requirement that does not
* exist in production.
*/
const PROCESS_HANDSHAKE_WATCHDOG_MS = 5_000;
const roots: string[] = []; const roots: string[] = [];
@@ -216,7 +223,7 @@ describe("provider guardian transaction protocol", () => {
})); }));
const ready = decodeProviderGuardianReady( const ready = decodeProviderGuardianReady(
await within(readyPayload, 1_000, "guardian READY"), await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian READY"),
nonce, nonce,
); );
const raw = await lstat(rawPath); const raw = await lstat(rawPath);
@@ -231,7 +238,7 @@ describe("provider guardian transaction protocol", () => {
.toEqual({ dev: ready.sealedDev, ino: ready.sealedIno, mode: 0o600 }); .toEqual({ dev: ready.sealedDev, ino: ready.sealedIno, mode: 0o600 });
child.stdin!.end(); child.stdin!.end();
await expect(within(completion, 1_000, "guardian abort")).resolves.toEqual({ await expect(within(completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian abort")).resolves.toEqual({
code: 125, code: 125,
signal: null, signal: null,
}); });
@@ -254,7 +261,7 @@ describe("provider guardian transaction protocol", () => {
if (input) child.stdin!.end(input); if (input) child.stdin!.end(input);
else child.stdin!.end(); else child.stdin!.end();
await expect(within(completion, 1_000, `${label} guardian EOF`)).resolves.toEqual({ await expect(within(completion, PROCESS_HANDSHAKE_WATCHDOG_MS, `${label} guardian EOF`)).resolves.toEqual({
code: expectedCode, code: expectedCode,
signal: null, signal: null,
}); });
@@ -289,7 +296,7 @@ describe("provider guardian transaction protocol", () => {
await expect(within( await expect(within(
waitForChild(fixture.child), waitForChild(fixture.child),
1_000, PROCESS_HANDSHAKE_WATCHDOG_MS,
`${boundKind} partial bootstrap exit`, `${boundKind} partial bootstrap exit`,
)).resolves.toEqual({ code: 126, signal: null }); )).resolves.toEqual({ code: 126, signal: null });
const boundPath = boundKind === "raw" ? fixture.rawStagingPath : fixture.sealedTempPath; const boundPath = boundKind === "raw" ? fixture.rawStagingPath : fixture.sealedTempPath;
@@ -320,7 +327,7 @@ describe("provider guardian transaction protocol", () => {
deadlineEpochMs: Date.now() + 2_000, deadlineEpochMs: Date.now() + 2_000,
})); }));
await expect(within(completion, 1_000, "guardian parent startup death")) await expect(within(completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian parent startup death"))
.resolves.toEqual({ code: 125, signal: null }); .resolves.toEqual({ code: 125, signal: null });
await expect(readdir(path.join(workspace, "provider-evidence/untrusted"))).resolves.toEqual([]); await expect(readdir(path.join(workspace, "provider-evidence/untrusted"))).resolves.toEqual([]);
await expect(readdir(path.join(workspace, "provider-evidence"))).resolves.toEqual(["untrusted"]); await expect(readdir(path.join(workspace, "provider-evidence"))).resolves.toEqual(["untrusted"]);
@@ -348,7 +355,7 @@ describe("provider guardian transaction protocol", () => {
deadlineEpochMs: Date.now() + 3_000, deadlineEpochMs: Date.now() + 3_000,
})); }));
const ready = decodeProviderGuardianReady( const ready = decodeProviderGuardianReady(
await within(readyPayload, 1_000, "guardian READY"), await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian READY"),
nonce, nonce,
); );
const sealedTempPath = path.join(evidenceRoot, ready.sealedTempLeaf); const sealedTempPath = path.join(evidenceRoot, ready.sealedTempLeaf);
@@ -379,7 +386,7 @@ describe("provider guardian transaction protocol", () => {
sha256: createHash("sha256").update(bytes).digest("hex"), sha256: createHash("sha256").update(bytes).digest("hex"),
})); }));
decodeProviderGuardianPublished( decodeProviderGuardianPublished(
await within(publishedPayload, 1_000, "guardian PUBLISHED"), await within(publishedPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian PUBLISHED"),
nonce, nonce,
{ dev: ready.sealedDev, ino: ready.sealedIno }, { dev: ready.sealedDev, ino: ready.sealedIno },
); );
@@ -393,7 +400,7 @@ describe("provider guardian transaction protocol", () => {
await new Promise<void>((resolve) => setTimeout(resolve, 50)); await new Promise<void>((resolve) => setTimeout(resolve, 50));
expect(child.exitCode).toBeNull(); expect(child.exitCode).toBeNull();
child.stdin!.end(); child.stdin!.end();
await expect(within(completion, 1_000, "guardian commit EOF")).resolves.toEqual({ await expect(within(completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian commit EOF")).resolves.toEqual({
code: 0, code: 0,
signal: null, signal: null,
}); });
@@ -456,7 +463,7 @@ describe("provider guardian transaction protocol", () => {
const transaction = await establishPublishedGuardian(workspace, Buffer.alloc(32, 0x31)); const transaction = await establishPublishedGuardian(workspace, Buffer.alloc(32, 0x31));
transaction.child.stdin!.end(); transaction.child.stdin!.end();
await expect(within(transaction.completion, 1_000, "published guardian EOF")) await expect(within(transaction.completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "published guardian EOF"))
.resolves.toEqual({ code: 125, signal: null }); .resolves.toEqual({ code: 125, signal: null });
await assertTransactionAbsent(transaction); await assertTransactionAbsent(transaction);
@@ -487,7 +494,7 @@ describe("provider guardian transaction protocol", () => {
transaction.child.stdin!.write(Buffer.from([0, 0, 0, 1, 0x7b])); transaction.child.stdin!.write(Buffer.from([0, 0, 0, 1, 0x7b]));
transaction.child.stdin!.end(); transaction.child.stdin!.end();
await expect(within(transaction.completion, 1_000, "guardian trailing frame")) await expect(within(transaction.completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian trailing frame"))
.resolves.toEqual({ code: 126, signal: null }); .resolves.toEqual({ code: 126, signal: null });
await assertTransactionAbsent(transaction); await assertTransactionAbsent(transaction);
}); });
@@ -505,7 +512,7 @@ describe("provider guardian transaction protocol", () => {
}); });
process.kill(lease.pid, "SIGKILL"); process.kill(lease.pid, "SIGKILL");
await expect(within(lease.prematureExit, 1_000, "guardian hard death")) await expect(within(lease.prematureExit, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian hard death"))
.resolves.toEqual(expect.objectContaining({ message: expect.stringMatching(/SIGKILL/u) })); .resolves.toEqual(expect.objectContaining({ message: expect.stringMatching(/SIGKILL/u) }));
await lease.abort(); await lease.abort();
await expect(lstat(lease.rawPath)).rejects.toMatchObject({ code: "ENOENT" }); await expect(lstat(lease.rawPath)).rejects.toMatchObject({ code: "ENOENT" });
@@ -536,8 +543,8 @@ describe("provider guardian transaction protocol", () => {
}); });
process.kill(guardianPid, "SIGCONT"); process.kill(guardianPid, "SIGCONT");
await within(rawLink, 1_000, "pre-READY canonical raw link"); await within(rawLink, PROCESS_HANDSHAKE_WATCHDOG_MS, "pre-READY canonical raw link");
await expect(within(starting, 2_000, "pre-READY guardian rejection")) await expect(within(starting, PROCESS_HANDSHAKE_WATCHDOG_MS, "pre-READY guardian rejection"))
.rejects.toThrow(/provider guardian/u); .rejects.toThrow(/provider guardian/u);
const possibleTempLeaves = (await readdir(evidenceRoot)).filter((leaf) => const possibleTempLeaves = (await readdir(evidenceRoot)).filter((leaf) =>
leaf.startsWith(".vulnerability-report.json.guardian-") leaf.startsWith(".vulnerability-report.json.guardian-")
@@ -587,7 +594,7 @@ describe("provider guardian transaction protocol", () => {
await canaryHandle.close(); await canaryHandle.close();
process.kill(guardianPid, "SIGKILL"); process.kill(guardianPid, "SIGKILL");
await expect(within(starting, 2_000, "external-canary guardian rejection")) await expect(within(starting, PROCESS_HANDSHAKE_WATCHDOG_MS, "external-canary guardian rejection"))
.rejects.toThrow(/provider guardian/u); .rejects.toThrow(/provider guardian/u);
expect(await readFile(rawPath)).toEqual(canaryBytes); expect(await readFile(rawPath)).toEqual(canaryBytes);
expect(await lstat(rawPath)).toMatchObject({ expect(await lstat(rawPath)).toMatchObject({
@@ -664,14 +671,14 @@ describe("provider guardian transaction protocol", () => {
deadlineEpochMs: Date.now() + 3_000, deadlineEpochMs: Date.now() + 3_000,
})); }));
const ready = decodeProviderGuardianReady( const ready = decodeProviderGuardianReady(
await within(readyPayload, 1_000, "closed-stderr guardian READY"), await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "closed-stderr guardian READY"),
nonce, nonce,
); );
child.stderr!.destroy(); child.stderr!.destroy();
await new Promise<void>((resolve) => child.stderr!.once("close", resolve)); await new Promise<void>((resolve) => child.stderr!.once("close", resolve));
child.stdin!.end(); child.stdin!.end();
await expect(within(completion, 1_000, "closed-stderr guardian exit")) await expect(within(completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "closed-stderr guardian exit"))
.resolves.toEqual({ code: 125, signal: null }); .resolves.toEqual({ code: 125, signal: null });
await expect(lstat(path.join(rawDirectory, "vulnerability-report.json"))) await expect(lstat(path.join(rawDirectory, "vulnerability-report.json")))
.rejects.toMatchObject({ code: "ENOENT" }); .rejects.toMatchObject({ code: "ENOENT" });
@@ -697,12 +704,12 @@ describe("provider guardian transaction protocol", () => {
deadlineEpochMs: Date.now() + 350, deadlineEpochMs: Date.now() + 350,
})); }));
const ready = decodeProviderGuardianReady( const ready = decodeProviderGuardianReady(
await within(readyPayload, 1_000, "deadline guardian READY"), await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "deadline guardian READY"),
nonce, nonce,
); );
const sealedTempPath = path.join(evidenceRoot, ready.sealedTempLeaf); const sealedTempPath = path.join(evidenceRoot, ready.sealedTempLeaf);
await expect(within(completion, 1_000, "guardian deadline")) await expect(within(completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian deadline"))
.resolves.toEqual({ code: null, signal: "SIGKILL" }); .resolves.toEqual({ code: null, signal: "SIGKILL" });
await expect(lstat(rawPath)).rejects.toMatchObject({ code: "ENOENT" }); await expect(lstat(rawPath)).rejects.toMatchObject({ code: "ENOENT" });
await expect(lstat(sealedTempPath)).rejects.toMatchObject({ code: "ENOENT" }); await expect(lstat(sealedTempPath)).rejects.toMatchObject({ code: "ENOENT" });
@@ -874,7 +881,7 @@ async function establishPublishedGuardian(
deadlineEpochMs: Date.now() + 3_000, deadlineEpochMs: Date.now() + 3_000,
})); }));
const ready = decodeProviderGuardianReady( const ready = decodeProviderGuardianReady(
await within(readyPayload, 1_000, "guardian READY"), await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian READY"),
nonce, nonce,
); );
const sealedTempPath = path.join(evidenceRoot, ready.sealedTempLeaf); const sealedTempPath = path.join(evidenceRoot, ready.sealedTempLeaf);
@@ -896,7 +903,7 @@ async function establishPublishedGuardian(
sha256: createHash("sha256").update(bytes).digest("hex"), sha256: createHash("sha256").update(bytes).digest("hex"),
})); }));
decodeProviderGuardianPublished( decodeProviderGuardianPublished(
await within(publishedPayload, 1_000, "guardian PUBLISHED"), await within(publishedPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian PUBLISHED"),
nonce, nonce,
{ dev: ready.sealedDev, ino: ready.sealedIno }, { dev: ready.sealedDev, ino: ready.sealedIno },
); );
+47
View File
@@ -200,6 +200,53 @@ describe("bounded body reader", () => {
}); });
}); });
it("abandons a pending bounded read when the operation is aborted", async () => {
const controller = new AbortController();
const reader = {
read: vi.fn(() => new Promise<never>(() => {})),
cancel: vi.fn().mockRejectedValue(new Error("cancel ignored")),
releaseLock: vi.fn(),
};
const response = {
headers: new Headers(),
body: { getReader: () => reader },
} as unknown as Response;
const pending = readBoundedBytes(response, 8, controller.signal);
controller.abort();
await expect(pending).resolves.toEqual({
ok: false,
code: "RESPONSE_STREAM_FAILURE",
});
expect(reader.cancel).toHaveBeenCalledOnce();
expect(reader.releaseLock).toHaveBeenCalledOnce();
});
it("abandons a pending forbidden-body probe when the operation is aborted", async () => {
const controller = new AbortController();
const reader = {
read: vi.fn(() => new Promise<never>(() => {})),
cancel: vi.fn().mockRejectedValue(new Error("cancel ignored")),
releaseLock: vi.fn(),
};
const response = {
headers: new Headers(),
body: { getReader: () => reader },
} as unknown as Response;
const pending = probeForbiddenBody(response, controller.signal);
controller.abort();
await expect(pending).resolves.toEqual({
ok: false,
code: "RESPONSE_STREAM_FAILURE",
});
expect(reader.cancel).toHaveBeenCalledOnce();
expect(reader.releaseLock).toHaveBeenCalledOnce();
});
it("decodes valid JSON and distinguishes UTF-8 from JSON failures", () => { it("decodes valid JSON and distinguishes UTF-8 from JSON failures", () => {
expect(decodeJsonBytes(new TextEncoder().encode('{"ok":true}'))).toEqual({ expect(decodeJsonBytes(new TextEncoder().encode('{"ok":true}'))).toEqual({
ok: true, ok: true,
+22 -16
View File
@@ -73,12 +73,17 @@ function labelledFileInput(): HTMLInputElement {
describe("browser file pickers", () => { describe("browser file pickers", () => {
it("treats native input cancellation as a normal dismissed outcome", async () => { it("treats native input cancellation as a normal dismissed outcome", async () => {
const input = labelledFileInput(); const input = labelledFileInput();
Object.defineProperty(input, "showPicker", { Object.defineProperty(input, "click", {
configurable: true, configurable: true,
value: vi.fn(() => { value: vi.fn(() => {
queueMicrotask(() => input.dispatchEvent(new Event("cancel"))); queueMicrotask(() => input.dispatchEvent(new Event("cancel")));
}), }),
}); });
const baselineShowPicker = vi.fn();
Object.defineProperty(input, "showPicker", {
configurable: true,
value: baselineShowPicker,
});
const harness = createHarness(); const harness = createHarness();
const picker = new NativeInputFilePicker({ const picker = new NativeInputFilePicker({
input, input,
@@ -90,6 +95,7 @@ describe("browser file pickers", () => {
ok: true, ok: true,
value: { kind: "DISMISSED" }, value: { kind: "DISMISSED" },
}); });
expect(baselineShowPicker).not.toHaveBeenCalled();
}); });
it("resets the native input and supports same-file reselection", async () => { it("resets the native input and supports same-file reselection", async () => {
@@ -102,12 +108,12 @@ describe("browser file pickers", () => {
configurable: true, configurable: true,
value: [selected], value: [selected],
}); });
const showPicker = vi.fn(() => { const click = vi.fn(() => {
queueMicrotask(() => input.dispatchEvent(new Event("change"))); queueMicrotask(() => input.dispatchEvent(new Event("change")));
}); });
Object.defineProperty(input, "showPicker", { Object.defineProperty(input, "click", {
configurable: true, configurable: true,
value: showPicker, value: click,
}); });
let sequence = 0; let sequence = 0;
const { policies, vault } = createHarness(policyDefinition, { const { policies, vault } = createHarness(policyDefinition, {
@@ -130,14 +136,14 @@ describe("browser file pickers", () => {
ok: true, ok: true,
value: { kind: "SELECTED" }, value: { kind: "SELECTED" },
}); });
expect(showPicker).toHaveBeenCalledTimes(2); expect(click).toHaveBeenCalledTimes(2);
expect(input.value).toBe(""); expect(input.value).toBe("");
expect(vault.activeReferenceCount).toBe(2); expect(vault.activeReferenceCount).toBe(2);
}); });
it("snapshots selection policy before awaiting picker events", async () => { it("snapshots selection policy before awaiting picker events", async () => {
const input = labelledFileInput(); const input = labelledFileInput();
Object.defineProperty(input, "showPicker", { Object.defineProperty(input, "click", {
configurable: true, configurable: true,
value: vi.fn(), value: vi.fn(),
}); });
@@ -186,7 +192,7 @@ describe("browser file pickers", () => {
it("honors AbortSignal while a native dialog is pending", async () => { it("honors AbortSignal while a native dialog is pending", async () => {
const input = labelledFileInput(); const input = labelledFileInput();
Object.defineProperty(input, "showPicker", { Object.defineProperty(input, "click", {
configurable: true, configurable: true,
value: vi.fn(), value: vi.fn(),
}); });
@@ -215,7 +221,7 @@ describe("browser file pickers", () => {
let fallback: (() => void) | undefined; let fallback: (() => void) | undefined;
let delay: number | undefined; let delay: number | undefined;
const clearTimeout = vi.fn(); const clearTimeout = vi.fn();
Object.defineProperty(input, "showPicker", { Object.defineProperty(input, "click", {
configurable: true, configurable: true,
value: vi.fn(() => { value: vi.fn(() => {
window.dispatchEvent(new Event("focus")); window.dispatchEvent(new Event("focus"));
@@ -261,7 +267,7 @@ describe("browser file pickers", () => {
const input = labelledFileInput(); const input = labelledFileInput();
let fallback: (() => void) | undefined; let fallback: (() => void) | undefined;
const clearTimeout = vi.fn(); const clearTimeout = vi.fn();
Object.defineProperty(input, "showPicker", { Object.defineProperty(input, "click", {
configurable: true, configurable: true,
value: vi.fn(() => { value: vi.fn(() => {
window.dispatchEvent(new Event("focus")); window.dispatchEvent(new Event("focus"));
@@ -316,14 +322,14 @@ describe("browser file pickers", () => {
setTimeout: originalSetTimeout, setTimeout: originalSetTimeout,
clearTimeout: originalClearTimeout, clearTimeout: originalClearTimeout,
}; };
const originalShowPicker = vi.fn(() => { const originalClick = vi.fn(() => {
focus?.(new Event("focus")); focus?.(new Event("focus"));
}); });
const replacedShowPicker = vi.fn(); const replacedClick = vi.fn();
Object.defineProperty(input, "showPicker", { Object.defineProperty(input, "click", {
configurable: true, configurable: true,
writable: true, writable: true,
value: originalShowPicker, value: originalClick,
}); });
const harness = createHarness(); const harness = createHarness();
const picker = new NativeInputFilePicker({ const picker = new NativeInputFilePicker({
@@ -338,7 +344,7 @@ describe("browser file pickers", () => {
focusFallbackGraceMs: 0, focusFallbackGraceMs: 0,
}); });
input.showPicker = replacedShowPicker; input.click = replacedClick;
windowHost.addEventListener = vi.fn(); windowHost.addEventListener = vi.fn();
windowHost.removeEventListener = vi.fn(); windowHost.removeEventListener = vi.fn();
scheduler.setTimeout = vi.fn(); scheduler.setTimeout = vi.fn();
@@ -348,8 +354,8 @@ describe("browser file pickers", () => {
ok: true, ok: true,
value: { kind: "DISMISSED" }, value: { kind: "DISMISSED" },
}); });
expect(originalShowPicker).toHaveBeenCalledOnce(); expect(originalClick).toHaveBeenCalledOnce();
expect(replacedShowPicker).not.toHaveBeenCalled(); expect(replacedClick).not.toHaveBeenCalled();
expect(originalWindowAdd).toHaveBeenCalledOnce(); expect(originalWindowAdd).toHaveBeenCalledOnce();
expect(originalWindowRemove).toHaveBeenCalledOnce(); expect(originalWindowRemove).toHaveBeenCalledOnce();
expect(originalSetTimeout).toHaveBeenCalledOnce(); expect(originalSetTimeout).toHaveBeenCalledOnce();
+1 -1
View File
@@ -178,7 +178,7 @@ describe("browser file runtime hard limits and disposal", () => {
it("aborts a pending native picker and prevents event resurrection", async () => { it("aborts a pending native picker and prevents event resurrection", async () => {
const input = fileInput(); const input = fileInput();
Object.defineProperty(input, "showPicker", { Object.defineProperty(input, "click", {
configurable: true, configurable: true,
value: vi.fn(), value: vi.fn(),
}); });
+3 -3
View File
@@ -189,9 +189,9 @@ describe("CI gate contract", () => {
), ),
); );
expect(contract.jobs).toHaveLength(9); expect(contract.jobs).toHaveLength(9);
expect(contract.commands).toHaveLength(84); expect(contract.commands).toHaveLength(91);
expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(96); expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(103);
expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(23); expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(30);
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(88); expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(88);
expect(contract.artifacts).toHaveLength(109); expect(contract.artifacts).toHaveLength(109);
expect(contract.stages).toHaveLength(5); expect(contract.stages).toHaveLength(5);
+18 -16
View File
@@ -16,7 +16,7 @@ import {
} from "../../src/features/installed-feature-contracts.ts"; } from "../../src/features/installed-feature-contracts.ts";
const COMPILED = Object.freeze([ const COMPILED = Object.freeze([
Object.freeze({ featureId: "reference-feature" }), Object.freeze({ featureId: "alpha-feature" }),
Object.freeze({ featureId: "billing" }), Object.freeze({ featureId: "billing" }),
]); ]);
@@ -26,7 +26,7 @@ describe("build-time product selection", () => {
expect( expect(
selectCompiledProductFeatures(COMPILED, declared).map((f) => f.featureId), selectCompiledProductFeatures(COMPILED, declared).map((f) => f.featureId),
String(declared), String(declared),
).toEqual(["reference-feature", "billing"]); ).toEqual(["alpha-feature", "billing"]);
} }
}); });
@@ -35,10 +35,10 @@ describe("build-time product selection", () => {
selectCompiledProductFeatures(COMPILED, "billing").map((f) => f.featureId), selectCompiledProductFeatures(COMPILED, "billing").map((f) => f.featureId),
).toEqual(["billing"]); ).toEqual(["billing"]);
expect( expect(
selectCompiledProductFeatures(COMPILED, " billing , reference-feature ").map( selectCompiledProductFeatures(COMPILED, " billing , alpha-feature ").map(
(f) => f.featureId, (f) => f.featureId,
), ),
).toEqual(["reference-feature", "billing"]); ).toEqual(["alpha-feature", "billing"]);
}); });
it("selects nothing only when asked explicitly", () => { it("selects nothing only when asked explicitly", () => {
@@ -79,13 +79,13 @@ describe("build-time product selection", () => {
describe("runtime product feature resolution", () => { describe("runtime product feature resolution", () => {
it("reports active, disabled and not-installed distinctly", () => { it("reports active, disabled and not-installed distinctly", () => {
const statuses = resolveProductFeatures( const statuses = resolveProductFeatures(
["reference-feature", "billing"], ["alpha-feature", "billing"],
["reference-feature"], ["alpha-feature"],
{ "reference-feature": "DISABLED" }, { "alpha-feature": "DISABLED" },
); );
expect(statuses).toEqual([ expect(statuses).toEqual([
{ featureId: "alpha-feature", state: "DISABLED_BY_CONFIG" },
{ featureId: "billing", state: "NOT_INSTALLED" }, { featureId: "billing", state: "NOT_INSTALLED" },
{ featureId: "reference-feature", state: "DISABLED_BY_CONFIG" },
]); ]);
expect(activeProductFeatureIds(statuses)).toEqual([]); expect(activeProductFeatureIds(statuses)).toEqual([]);
}); });
@@ -103,11 +103,11 @@ describe("runtime product feature resolution", () => {
// A shared runtime document may cover several builds, so a stale key is // A shared runtime document may cover several builds, so a stale key is
// inert rather than fatal. // inert rather than fatal.
const statuses = resolveProductFeatures( const statuses = resolveProductFeatures(
["reference-feature"], ["alpha-feature"],
["reference-feature"], ["alpha-feature"],
{ analytics: "DISABLED" }, { analytics: "DISABLED" },
); );
expect(activeProductFeatureIds(statuses)).toEqual(["reference-feature"]); expect(activeProductFeatureIds(statuses)).toEqual(["alpha-feature"]);
}); });
it("leaves an installed feature active without an override", () => { it("leaves an installed feature active without an override", () => {
@@ -140,15 +140,15 @@ describe("runtime config carries the switch", () => {
expect( expect(
runtimeConfigV2ArtifactSchema.parse({ runtimeConfigV2ArtifactSchema.parse({
...base, ...base,
FEATURE_OVERRIDES: { "reference-feature": "DISABLED" }, FEATURE_OVERRIDES: { "alpha-feature": "DISABLED" },
}).FEATURE_OVERRIDES, }).FEATURE_OVERRIDES,
).toEqual({ "reference-feature": "DISABLED" }); ).toEqual({ "alpha-feature": "DISABLED" });
// There is no "ENABLED": the vocabulary itself is what makes the rule // There is no "ENABLED": the vocabulary itself is what makes the rule
// unbreakable, not a check somewhere downstream. // unbreakable, not a check somewhere downstream.
expect( expect(
runtimeConfigV2ArtifactSchema.safeParse({ runtimeConfigV2ArtifactSchema.safeParse({
...base, ...base,
FEATURE_OVERRIDES: { "reference-feature": "ENABLED" }, FEATURE_OVERRIDES: { "alpha-feature": "ENABLED" },
}).success, }).success,
).toBe(false); ).toBe(false);
}); });
@@ -212,11 +212,13 @@ describe("route ownership", () => {
} }
}); });
it("owns exactly the routes the registry received from features", () => { it("owns exactly routes that exist in the composed registry", () => {
const owned = Object.keys(ROUTE_FEATURE_OWNER); const owned = Object.keys(ROUTE_FEATURE_OWNER);
expect(owned.length).toBeGreaterThan(0);
for (const routeId of owned) { for (const routeId of owned) {
expect(Object.keys(ROUTE_REGISTRY), routeId).toContain(routeId); expect(Object.keys(ROUTE_REGISTRY), routeId).toContain(routeId);
} }
expect(
owned.every((routeId) => ROUTE_FEATURE_OWNER[routeId] !== undefined),
).toBe(true);
}); });
}); });
+137
View File
@@ -0,0 +1,137 @@
import type {
PublicCacheAsset,
PublicCacheReleaseManifest,
} from "../../src/application/ports/browser-file-storage/cache-storage-ports.ts";
import {
createDefaultPublicCachePolicy,
type PublicCacheRuntimePolicy,
} from "../../src/adapters/cache-storage/public-cache-policy.ts";
import {
computePublicCacheManifestDigestHex,
type PublicCacheMutationLock,
} from "../../src/adapters/cache-storage/public-response-cache-adapter.ts";
export class MemoryCache {
readonly responses: Array<Readonly<{
request: Request;
response: Response;
}>> = [];
async match(request: RequestInfo | URL): Promise<Response | undefined> {
const url =
request instanceof Request ? request.url : new URL(String(request)).href;
const nativeRequest =
request instanceof Request ? request : new Request(url);
return this.responses
.find(
(entry) =>
entry.request.url === url &&
varyMatches(entry.request, nativeRequest, entry.response),
)
?.response.clone();
}
async put(request: RequestInfo | URL, response: Response): Promise<void> {
const url =
request instanceof Request ? request.url : new URL(String(request)).href;
const nativeRequest =
request instanceof Request ? request.clone() : new Request(url);
const existing = this.responses.findIndex(
(entry) =>
entry.request.url === url &&
varyMatches(entry.request, nativeRequest, response),
);
const entry = {
request: nativeRequest,
response: response.clone(),
};
if (existing >= 0) this.responses.splice(existing, 1, entry);
else this.responses.push(entry);
}
}
function varyMatches(
storedRequest: Request,
incomingRequest: Request,
response: Response,
): boolean {
const vary = response.headers.get("vary");
if (!vary) return true;
return vary
.split(",")
.map((name) => name.trim().toLowerCase())
.every(
(name) =>
storedRequest.headers.get(name) ===
incomingRequest.headers.get(name),
);
}
export class MemoryCacheStorage {
readonly caches = new Map<string, MemoryCache>();
async open(name: string): Promise<Cache> {
let cache = this.caches.get(name);
if (!cache) {
cache = new MemoryCache();
this.caches.set(name, cache);
}
return cache as unknown as Cache;
}
async keys(): Promise<string[]> {
return [...this.caches.keys()];
}
async delete(name: string): Promise<boolean> {
return this.caches.delete(name);
}
}
export const immediateLock: PublicCacheMutationLock = {
async run(_signal, task) {
return await task();
},
};
export function deferred<Value>() {
let settle: ((value: Value) => void) | undefined;
const promise = new Promise<Value>((resolve) => {
settle = resolve;
});
return Object.freeze({
promise,
resolve(value: Value): void {
settle?.(value);
},
});
}
export async function digestHex(bytes: Uint8Array): Promise<string> {
const digest = await globalThis.crypto.subtle.digest(
"SHA-256",
Uint8Array.from(bytes),
);
return [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
export async function manifestFor(
releaseRegistryId: string,
assets: readonly PublicCacheAsset[],
policy: PublicCacheRuntimePolicy = createDefaultPublicCachePolicy(
"https://assets.example.test",
),
): Promise<PublicCacheReleaseManifest> {
return {
releaseRegistryId,
assets,
manifestDigestHex: await computePublicCacheManifestDigestHex(
globalThis.crypto,
releaseRegistryId,
assets,
policy,
),
};
}
@@ -0,0 +1,207 @@
import { describe, expect, it } from "vitest";
import type { PublicCacheAsset } from "../../src/application/ports/browser-file-storage/cache-storage-ports.ts";
import { createDefaultPublicCachePolicy } from "../../src/adapters/cache-storage/public-cache-policy.ts";
import { createPublicResponseCacheAdapter } from "../../src/adapters/cache-storage/public-response-cache-adapter.ts";
import {
MemoryCacheStorage,
digestHex,
immediateLock,
manifestFor,
} from "./public-response-cache-fixture.ts";
/**
* STO-RR-04 / STO-RR-05. A release cache that is currently serving traffic is
* the last thing a repair may destroy. A transient marker read failure is not
* evidence of damage, and a repair that has not yet fetched anything has not
* yet earned the right to delete what still works.
*/
describe("public response cache repair is failure-atomic", () => {
async function stagedRelease(releaseRegistryId: string) {
const policy = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const firstBytes = new Uint8Array([1, 1, 1, 1]);
const secondBytes = new Uint8Array([2, 2, 2, 2]);
const assets: readonly PublicCacheAsset[] = [
{
absoluteUrl: "https://assets.example.test/first.js",
expectedByteLength: firstBytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(firstBytes),
},
},
{
absoluteUrl: "https://assets.example.test/second.js",
expectedByteLength: secondBytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(secondBytes),
},
},
];
const bodies = new Map<string, Uint8Array>([
[assets[0]!.absoluteUrl, firstBytes],
[assets[1]!.absoluteUrl, secondBytes],
]);
const cacheStorage = new MemoryCacheStorage();
const fetchLog: string[] = [];
let failFrom: string | null = null;
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
fetcher: async (request: Request) => {
fetchLog.push(request.url);
if (failFrom !== null && request.url === failFrom) {
throw new TypeError("network is down");
}
const body = bodies.get(request.url);
if (!body) throw new TypeError(`unknown asset ${request.url}`);
return new Response(Uint8Array.from(body), {
headers: {
"cache-control": "public",
"content-type": "application/javascript",
},
});
},
});
const manifest = await manifestFor(releaseRegistryId, assets, policy);
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
ok: true,
});
expect(
await adapter.admin.activateRelease(
manifest.releaseRegistryId,
manifest.manifestDigestHex,
),
).toMatchObject({ ok: true });
const cacheName = [...cacheStorage.caches.keys()].find((name) =>
name.includes(releaseRegistryId),
);
if (!cacheName) throw new Error("staged cache missing");
return {
adapter,
assets,
cacheName,
cacheStorage,
fetchLog,
manifest,
setFailure(url: string | null) {
failFrom = url;
},
};
}
it("does not delete an active candidate when the marker read fails transiently", async () => {
const release = await stagedRelease("transient-marker");
const cache = release.cacheStorage.caches.get(release.cacheName)!;
const realMatch = cache.match.bind(cache);
let markerReads = 0;
const assetUrls = new Set(release.assets.map((asset) => asset.absoluteUrl));
cache.match = async (request: RequestInfo | URL) => {
const url =
request instanceof Request ? request.url : String(request);
if (!assetUrls.has(url)) {
markerReads += 1;
throw new DOMException("Storage is busy", "InvalidStateError");
}
return await realMatch(request);
};
const restaged = await release.adapter.admin.stageRelease(release.manifest);
expect(markerReads).toBeGreaterThan(0);
expect(restaged.ok).toBe(false);
expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true);
cache.match = realMatch;
expect(
await release.adapter.responses.matchActiveExact({
absoluteUrl: release.assets[0]!.absoluteUrl,
}),
).toMatchObject({ ok: true });
});
it("keeps every healthy asset when one repair fetch fails", async () => {
const release = await stagedRelease("partial-repair");
const cache = release.cacheStorage.caches.get(release.cacheName)!;
// Corrupt only the first asset's stored bytes.
const corrupted = cache.responses.findIndex(
(entry) => entry.request.url === release.assets[0]!.absoluteUrl,
);
expect(corrupted).toBeGreaterThanOrEqual(0);
cache.responses.splice(corrupted, 1);
release.setFailure(release.assets[0]!.absoluteUrl);
const restaged = await release.adapter.admin.stageRelease(release.manifest);
expect(restaged.ok).toBe(false);
// The cache still exists and the healthy asset is still served.
expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true);
expect(
await release.adapter.responses.matchActiveExact({
absoluteUrl: release.assets[1]!.absoluteUrl,
}),
).toMatchObject({ ok: true });
});
it("still removes a candidate this call created when staging fails", async () => {
const policy = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const bytes = new Uint8Array([7, 7, 7, 7]);
const asset: PublicCacheAsset = {
absoluteUrl: "https://assets.example.test/fresh.js",
expectedByteLength: bytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(bytes),
},
};
const cacheStorage = new MemoryCacheStorage();
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
fetcher: async () => {
throw new TypeError("network is down");
},
});
const manifest = await manifestFor("fresh-release", [asset], policy);
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
ok: false,
});
expect(await cacheStorage.keys()).toEqual([]);
});
it("repairs an evicted asset in place and keeps the release usable", async () => {
const release = await stagedRelease("in-place-repair");
const cache = release.cacheStorage.caches.get(release.cacheName)!;
const evicted = cache.responses.findIndex(
(entry) => entry.request.url === release.assets[1]!.absoluteUrl,
);
cache.responses.splice(evicted, 1);
expect(
await release.adapter.admin.stageRelease(release.manifest),
).toMatchObject({ ok: true });
expect(
await release.adapter.responses.matchActiveExact({
absoluteUrl: release.assets[1]!.absoluteUrl,
}),
).toMatchObject({ ok: true });
expect(
await release.adapter.responses.matchActiveExact({
absoluteUrl: release.assets[0]!.absoluteUrl,
}),
).toMatchObject({ ok: true });
});
});
+7 -321
View File
@@ -10,135 +10,17 @@ import {
type PublicCacheRuntimePolicy, type PublicCacheRuntimePolicy,
} from "../../src/adapters/cache-storage/public-cache-policy.ts"; } from "../../src/adapters/cache-storage/public-cache-policy.ts";
import { import {
computePublicCacheManifestDigestHex,
createPublicResponseCacheAdapter, createPublicResponseCacheAdapter,
type PublicCacheMutationLock, type PublicCacheMutationLock,
} from "../../src/adapters/cache-storage/public-response-cache-adapter.ts"; } from "../../src/adapters/cache-storage/public-response-cache-adapter.ts";
class MemoryCache { import {
readonly responses: Array<Readonly<{ MemoryCacheStorage,
request: Request; deferred,
response: Response; digestHex,
}>> = []; immediateLock,
manifestFor,
async match(request: RequestInfo | URL): Promise<Response | undefined> { } from "./public-response-cache-fixture.ts";
const url =
request instanceof Request ? request.url : new URL(String(request)).href;
const nativeRequest =
request instanceof Request ? request : new Request(url);
return this.responses
.find(
(entry) =>
entry.request.url === url &&
varyMatches(entry.request, nativeRequest, entry.response),
)
?.response.clone();
}
async put(request: RequestInfo | URL, response: Response): Promise<void> {
const url =
request instanceof Request ? request.url : new URL(String(request)).href;
const nativeRequest =
request instanceof Request ? request.clone() : new Request(url);
const existing = this.responses.findIndex(
(entry) =>
entry.request.url === url &&
varyMatches(entry.request, nativeRequest, response),
);
const entry = {
request: nativeRequest,
response: response.clone(),
};
if (existing >= 0) this.responses.splice(existing, 1, entry);
else this.responses.push(entry);
}
}
function varyMatches(
storedRequest: Request,
incomingRequest: Request,
response: Response,
): boolean {
const vary = response.headers.get("vary");
if (!vary) return true;
return vary
.split(",")
.map((name) => name.trim().toLowerCase())
.every(
(name) =>
storedRequest.headers.get(name) ===
incomingRequest.headers.get(name),
);
}
class MemoryCacheStorage {
readonly caches = new Map<string, MemoryCache>();
async open(name: string): Promise<Cache> {
let cache = this.caches.get(name);
if (!cache) {
cache = new MemoryCache();
this.caches.set(name, cache);
}
return cache as unknown as Cache;
}
async keys(): Promise<string[]> {
return [...this.caches.keys()];
}
async delete(name: string): Promise<boolean> {
return this.caches.delete(name);
}
}
const immediateLock: PublicCacheMutationLock = {
async run(_signal, task) {
return await task();
},
};
function deferred<Value>() {
let settle: ((value: Value) => void) | undefined;
const promise = new Promise<Value>((resolve) => {
settle = resolve;
});
return Object.freeze({
promise,
resolve(value: Value): void {
settle?.(value);
},
});
}
async function digestHex(bytes: Uint8Array): Promise<string> {
const digest = await globalThis.crypto.subtle.digest(
"SHA-256",
Uint8Array.from(bytes),
);
return [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
async function manifestFor(
releaseRegistryId: string,
assets: readonly PublicCacheAsset[],
policy: PublicCacheRuntimePolicy = createDefaultPublicCachePolicy(
"https://assets.example.test",
),
): Promise<PublicCacheReleaseManifest> {
return {
releaseRegistryId,
assets,
manifestDigestHex: await computePublicCacheManifestDigestHex(
globalThis.crypto,
releaseRegistryId,
assets,
policy,
),
};
}
describe("public response Cache Storage adapter", () => { describe("public response Cache Storage adapter", () => {
it("rejects a policy that enables variants but strips Vary", () => { it("rejects a policy that enables variants but strips Vary", () => {
@@ -1513,199 +1395,3 @@ describe("public response Cache Storage adapter", () => {
expect(await cacheStorage.keys()).toEqual([]); expect(await cacheStorage.keys()).toEqual([]);
}); });
}); });
/**
* STO-RR-04 / STO-RR-05. A release cache that is currently serving traffic is
* the last thing a repair may destroy. A transient marker read failure is not
* evidence of damage, and a repair that has not yet fetched anything has not
* yet earned the right to delete what still works.
*/
describe("public response cache repair is failure-atomic", () => {
async function stagedRelease(releaseRegistryId: string) {
const policy = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const firstBytes = new Uint8Array([1, 1, 1, 1]);
const secondBytes = new Uint8Array([2, 2, 2, 2]);
const assets: readonly PublicCacheAsset[] = [
{
absoluteUrl: "https://assets.example.test/first.js",
expectedByteLength: firstBytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(firstBytes),
},
},
{
absoluteUrl: "https://assets.example.test/second.js",
expectedByteLength: secondBytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(secondBytes),
},
},
];
const bodies = new Map<string, Uint8Array>([
[assets[0]!.absoluteUrl, firstBytes],
[assets[1]!.absoluteUrl, secondBytes],
]);
const cacheStorage = new MemoryCacheStorage();
const fetchLog: string[] = [];
let failFrom: string | null = null;
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
fetcher: async (request: Request) => {
fetchLog.push(request.url);
if (failFrom !== null && request.url === failFrom) {
throw new TypeError("network is down");
}
const body = bodies.get(request.url);
if (!body) throw new TypeError(`unknown asset ${request.url}`);
return new Response(Uint8Array.from(body), {
headers: {
"cache-control": "public",
"content-type": "application/javascript",
},
});
},
});
const manifest = await manifestFor(releaseRegistryId, assets, policy);
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
ok: true,
});
expect(
await adapter.admin.activateRelease(
manifest.releaseRegistryId,
manifest.manifestDigestHex,
),
).toMatchObject({ ok: true });
const cacheName = [...cacheStorage.caches.keys()].find((name) =>
name.includes(releaseRegistryId),
);
if (!cacheName) throw new Error("staged cache missing");
return {
adapter,
assets,
cacheName,
cacheStorage,
fetchLog,
manifest,
setFailure(url: string | null) {
failFrom = url;
},
};
}
it("does not delete an active candidate when the marker read fails transiently", async () => {
const release = await stagedRelease("transient-marker");
const cache = release.cacheStorage.caches.get(release.cacheName)!;
const realMatch = cache.match.bind(cache);
let markerReads = 0;
const assetUrls = new Set(release.assets.map((asset) => asset.absoluteUrl));
cache.match = async (request: RequestInfo | URL) => {
const url =
request instanceof Request ? request.url : String(request);
if (!assetUrls.has(url)) {
markerReads += 1;
throw new DOMException("Storage is busy", "InvalidStateError");
}
return await realMatch(request);
};
const restaged = await release.adapter.admin.stageRelease(release.manifest);
expect(markerReads).toBeGreaterThan(0);
expect(restaged.ok).toBe(false);
expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true);
cache.match = realMatch;
expect(
await release.adapter.responses.matchActiveExact({
absoluteUrl: release.assets[0]!.absoluteUrl,
}),
).toMatchObject({ ok: true });
});
it("keeps every healthy asset when one repair fetch fails", async () => {
const release = await stagedRelease("partial-repair");
const cache = release.cacheStorage.caches.get(release.cacheName)!;
// Corrupt only the first asset's stored bytes.
const corrupted = cache.responses.findIndex(
(entry) => entry.request.url === release.assets[0]!.absoluteUrl,
);
expect(corrupted).toBeGreaterThanOrEqual(0);
cache.responses.splice(corrupted, 1);
release.setFailure(release.assets[0]!.absoluteUrl);
const restaged = await release.adapter.admin.stageRelease(release.manifest);
expect(restaged.ok).toBe(false);
// The cache still exists and the healthy asset is still served.
expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true);
expect(
await release.adapter.responses.matchActiveExact({
absoluteUrl: release.assets[1]!.absoluteUrl,
}),
).toMatchObject({ ok: true });
});
it("still removes a candidate this call created when staging fails", async () => {
const policy = createDefaultPublicCachePolicy(
"https://assets.example.test",
);
const bytes = new Uint8Array([7, 7, 7, 7]);
const asset: PublicCacheAsset = {
absoluteUrl: "https://assets.example.test/fresh.js",
expectedByteLength: bytes.byteLength,
expectedContentType: "application/javascript",
integrity: {
algorithm: "SHA-256",
digestHex: await digestHex(bytes),
},
};
const cacheStorage = new MemoryCacheStorage();
const adapter = createPublicResponseCacheAdapter({
cacheStorage: cacheStorage as unknown as CacheStorage,
crypto: globalThis.crypto,
mutationLock: immediateLock,
policy,
fetcher: async () => {
throw new TypeError("network is down");
},
});
const manifest = await manifestFor("fresh-release", [asset], policy);
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
ok: false,
});
expect(await cacheStorage.keys()).toEqual([]);
});
it("repairs an evicted asset in place and keeps the release usable", async () => {
const release = await stagedRelease("in-place-repair");
const cache = release.cacheStorage.caches.get(release.cacheName)!;
const evicted = cache.responses.findIndex(
(entry) => entry.request.url === release.assets[1]!.absoluteUrl,
);
cache.responses.splice(evicted, 1);
expect(
await release.adapter.admin.stageRelease(release.manifest),
).toMatchObject({ ok: true });
expect(
await release.adapter.responses.matchActiveExact({
absoluteUrl: release.assets[1]!.absoluteUrl,
}),
).toMatchObject({ ok: true });
expect(
await release.adapter.responses.matchActiveExact({
absoluteUrl: release.assets[0]!.absoluteUrl,
}),
).toMatchObject({ ok: true });
});
});
@@ -0,0 +1,341 @@
import { vi } from "vitest";
import type {
ResumableUploadCheckpoint,
ResumableUploadCheckpointStore,
ResumableUploadControlPlane,
ResumableUploadSource,
UploadPartExecutor,
UploadPartReceipt,
UploadProviderResult,
UploadSession,
} from "../../src/application/ports/browser-transfer/resumable-upload.ts";
import { RESUMABLE_UPLOAD_PROTOCOL } from "../../src/application/ports/browser-transfer/resumable-upload.ts";
import type {
BrowserDataResult,
} from "../../src/application/ports/browser-file-storage/shared.ts";
import {
browserDataFailure,
browserDataSuccess,
} from "../../src/adapters/browser-file-storage/result.ts";
import { resolveResumableUploadRuntimePolicy } from "../../src/adapters/browser-transfer/resumable-upload/runtime-policy.ts";
import type {
UploadCancellationChannel,
UploadCancellationListener,
} from "../../src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts";
import type { UploadMutationLock } from "../../src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts";
export type TestCapability = Readonly<{ id: string }>;
export const activeSignal = new AbortController().signal;
export const noContentionLock: UploadMutationLock = Object.freeze({
async run<Value>(
_uploadKey: string,
_signal: AbortSignal,
task: () => Promise<Value>,
): Promise<Value> {
return await task();
},
});
export function createSerialMutationLock(): UploadMutationLock {
let tail = Promise.resolve();
return Object.freeze({
run<Value>(
_uploadKey: string,
signal: AbortSignal,
task: () => Promise<Value>,
): Promise<Value> {
const result = tail.then(async () => {
if (signal.aborted) {
throw new DOMException(
"The operation was aborted.",
"AbortError",
);
}
return await task();
});
tail = result.then(
() => undefined,
() => undefined,
);
return result;
},
});
}
export function createMemoryCancellationPair(): readonly [
UploadCancellationChannel,
UploadCancellationChannel,
] {
const listeners = [
new Set<UploadCancellationListener>(),
new Set<UploadCancellationListener>(),
] as const;
const channels = listeners.map((ownListeners, ownIndex) => {
let closed = false;
return Object.freeze({
publish(uploadKey: string) {
if (closed) return false;
for (const [index, peerListeners] of listeners.entries()) {
if (index === ownIndex) continue;
for (const listener of [...peerListeners]) {
listener(uploadKey);
}
}
return true;
},
subscribe(listener: UploadCancellationListener) {
if (closed) throw new TypeError("closed");
ownListeners.add(listener);
return () => ownListeners.delete(listener);
},
close() {
closed = true;
ownListeners.clear();
},
});
});
return channels as unknown as readonly [
UploadCancellationChannel,
UploadCancellationChannel,
];
}
export class MemoryCheckpointStore implements ResumableUploadCheckpointStore {
readonly rows = new Map<string, ResumableUploadCheckpoint>();
closed = false;
async read(
uploadKey: string,
): Promise<BrowserDataResult<ResumableUploadCheckpoint | null>> {
return browserDataSuccess(
structuredClone(this.rows.get(uploadKey) ?? null),
);
}
async compareAndSwap(
input: Parameters<
ResumableUploadCheckpointStore["compareAndSwap"]
>[0],
): Promise<BrowserDataResult<ResumableUploadCheckpoint>> {
const current = this.rows.get(input.checkpoint.uploadKey);
if (
(input.expectedRevision === null && current) ||
(input.expectedRevision !== null &&
current?.revision !== input.expectedRevision)
) {
return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", {
recovery: "RECONCILE",
});
}
const snapshot = structuredClone(input.checkpoint);
this.rows.set(snapshot.uploadKey, snapshot);
return browserDataSuccess(snapshot);
}
async remove(
input: Parameters<ResumableUploadCheckpointStore["remove"]>[0],
): Promise<BrowserDataResult<void>> {
const current = this.rows.get(input.uploadKey);
if (current?.revision !== input.expectedRevision) {
return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", {
recovery: "RECONCILE",
});
}
this.rows.delete(input.uploadKey);
return browserDataSuccess(undefined);
}
close(): void {
this.closed = true;
}
}
export function rangeSource(bytes: Uint8Array): ResumableUploadSource {
return Object.freeze({
kind: "RANGE_READER" as const,
reader: Object.freeze({
byteLength: bytes.byteLength,
async readRange(input: Readonly<{
offset: number;
length: number;
signal: AbortSignal;
}>) {
if (input.signal.aborted) {
return browserDataFailure("ABORTED", "FILE_READ");
}
return browserDataSuccess(
bytes.slice(input.offset, input.offset + input.length),
);
},
}),
});
}
export function byteStreamSource(bytes: Uint8Array): ResumableUploadSource {
return Object.freeze({
kind: "FILE_BYTE_SOURCE" as const,
bytes: Object.freeze({
byteLength: bytes.byteLength,
async *stream(signal: AbortSignal) {
if (signal.aborted) {
yield browserDataFailure("ABORTED", "FILE_READ");
return;
}
yield browserDataSuccess(bytes.slice(0, 3));
yield browserDataSuccess(bytes.slice(3));
},
}),
});
}
export function runtimePolicy(
overrides: Partial<
Parameters<typeof resolveResumableUploadRuntimePolicy>[0]
> = {},
) {
return {
partSizeBytes: 4,
maxFileBytes: 100,
maxPartCount: 25,
maxConcurrency: 3,
maxInFlightBytes: 48,
partBufferCopyFactor: 4,
maxSourceChunkBytes: 8,
maxRetries: 2,
retryBaseDelayMs: 1,
retryMaxDelayMs: 10,
maxRetryAfterMs: 100,
capabilityRefreshSkewMs: 5,
maxSessionLifetimeMs: 10_000,
providerAttemptTimeoutMs: 100,
...overrides,
};
}
export type ControlHarness = Readonly<{
control: ResumableUploadControlPlane<TestCapability>;
accepted: Map<number, UploadPartReceipt>;
issued: ReturnType<typeof vi.fn>;
completedParts: UploadPartReceipt[][];
getSession(): UploadSession | null;
}>;
export function createControlHarness(options: Readonly<{
now?: number;
serverMaxConcurrency?: number;
sessionId?: (createIndex: number) => string;
statusParts?: (
session: UploadSession,
accepted: Map<number, UploadPartReceipt>,
) => readonly UploadPartReceipt[];
issueCapability?: (
input: Parameters<
ResumableUploadControlPlane<TestCapability>["issuePartCapability"]
>[0],
callIndex: number,
) => UploadProviderResult<Readonly<{
capability: TestCapability;
uploadBindingSha256: string;
expiresAtEpochMs: number;
}>>;
}> = {}): ControlHarness {
const now = options.now ?? 1_000;
const accepted = new Map<number, UploadPartReceipt>();
const completedParts: UploadPartReceipt[][] = [];
let session: UploadSession | null = null;
let createCount = 0;
let issueCount = 0;
const issued = vi.fn();
const control: ResumableUploadControlPlane<TestCapability> = {
async createSession(input) {
createCount += 1;
session = Object.freeze({
protocol: RESUMABLE_UPLOAD_PROTOCOL,
sessionId:
options.sessionId?.(createCount) ?? "session_01",
requestBindingSha256: input.requestBindingSha256,
fingerprint: input.fingerprint,
partSizeBytes: input.requestedPartSizeBytes,
partCount: input.fingerprint.partCount,
maxConcurrency: options.serverMaxConcurrency ?? 2,
expiresAtEpochMs: now + 5_000,
});
return browserDataSuccess(session);
},
async getStatus() {
if (!session) {
return browserDataFailure("NOT_FOUND", "UPLOAD_RECONCILE");
}
const parts =
options.statusParts?.(session, accepted) ??
[...accepted.values()].sort(
(left, right) => left.partNumber - right.partNumber,
);
return browserDataSuccess({
state: "ACTIVE",
session,
acceptedParts: parts,
});
},
async issuePartCapability(input) {
issueCount += 1;
issued(input);
return (
options.issueCapability?.(input, issueCount) ??
browserDataSuccess({
capability: Object.freeze({ id: `cap-${issueCount}` }),
uploadBindingSha256: input.uploadBindingSha256,
expiresAtEpochMs: now + 4_000,
})
);
},
async complete(input) {
completedParts.push([...input.orderedParts]);
return browserDataSuccess({
state: "QUARANTINED",
protocol: RESUMABLE_UPLOAD_PROTOCOL,
sessionId: input.sessionId,
requestBindingSha256: input.requestBindingSha256,
fingerprint: input.fingerprint,
resourceId: "resource_01",
});
},
async abort() {
return browserDataSuccess({ state: "ABORTED" });
},
};
return {
control,
accepted,
issued,
completedParts,
getSession: () => session,
};
}
export function executorFor(
harness: ControlHarness,
options: Readonly<{
delay?: () => Promise<void>;
onActive?: (active: number) => void;
}> = {},
): UploadPartExecutor<TestCapability> {
let active = 0;
return {
async uploadPart(input) {
active += 1;
options.onActive?.(active);
await options.delay?.();
active -= 1;
const receipt = Object.freeze({
...input.part,
receiptToken: `etag-part-${input.part.partNumber}`,
});
harness.accepted.set(input.part.partNumber, receipt);
return browserDataSuccess(receipt);
},
};
}
@@ -0,0 +1,188 @@
import { describe, expect, it, vi } from "vitest";
import { browserDataFailure } from "../../src/adapters/browser-file-storage/result.ts";
import { createResumableUploadRuntime } from "../../src/adapters/browser-transfer/resumable-upload/resumable-upload-runtime.ts";
import {
activeSignal,
byteStreamSource,
createControlHarness,
executorFor,
MemoryCheckpointStore,
noContentionLock,
runtimePolicy,
} from "./resumable-upload-runtime-fixture.ts";
/**
* TR-RR-06. A non-cooperative mutation lock or provider must not make teardown
* unbounded: `dispose()` bounds its drain and reports honestly when the runtime
* is still CLOSING, and an abort is admitted physical work it cannot step over.
*/
describe("TR-RR-06 bounded resumable teardown", () => {
it("reports an unproved drain instead of waiting forever", async () => {
const checkpoints = new MemoryCheckpointStore();
const harness = createControlHarness();
const runtime = createResumableUploadRuntime({
controlPlane: harness.control,
partExecutor: executorFor(harness, { delay: async () => {} }),
checkpoints,
// A lock that never grants: dispose must still be bounded.
mutationLock: Object.freeze({
async run<Value>(): Promise<Value> {
return await new Promise<never>(() => {});
},
}),
crypto,
policy: runtimePolicy({ cleanupDeadlineMs: 20 }),
now: () => 1_000,
random: () => 0,
sleep: async () => {},
});
void runtime.upload({
uploadKey: "upload_key_hung",
purpose: "attachment",
mediaType: "application/octet-stream",
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
signal: activeSignal,
});
await Promise.resolve();
await Promise.resolve();
const disposed = await runtime.dispose();
expect(disposed.ok).toBe(false);
// Still CLOSING: physical work the caller must not treat as finished.
expect(runtime.lifecycle()).toBe("CLOSING");
expect(checkpoints.closed).toBe(false);
});
it("closes once every admitted operation settles", async () => {
const checkpoints = new MemoryCheckpointStore();
const harness = createControlHarness();
const runtime = createResumableUploadRuntime({
controlPlane: harness.control,
partExecutor: executorFor(harness, { delay: async () => {} }),
checkpoints,
mutationLock: noContentionLock,
crypto,
policy: runtimePolicy({ cleanupDeadlineMs: 200 }),
now: () => 1_000,
random: () => 0,
sleep: async () => {},
});
const disposed = await runtime.dispose();
expect(disposed).toMatchObject({ ok: true });
expect(runtime.lifecycle()).toBe("CLOSED");
expect(checkpoints.closed).toBe(true);
});
});
/**
* TR-04. `dispose()` proved quiescence from the wrapper registry only. A
* provider that ignored its attempt deadline let the wrapper settle first and
* leave the set empty, so teardown reported a drained runtime and closed the
* checkpoint store while the provider was still running.
*/
describe("TR-04 teardown waits for raw provider work, not only its wrapper", () => {
it("refuses to report a drained runtime while a provider is still running", async () => {
const harness = createControlHarness();
const checkpoints = new MemoryCheckpointStore();
const closeStore = vi.spyOn(checkpoints, "close");
let releaseProvider: (() => void) | undefined;
// Ignores the attempt signal entirely and outlives its own deadline.
harness.control.createSession = () =>
new Promise((resolve) => {
releaseProvider = () =>
resolve(
browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", {
retryable: true,
recovery: "RESUME",
}),
);
});
const runtime = createResumableUploadRuntime({
controlPlane: harness.control,
partExecutor: executorFor(harness),
checkpoints,
mutationLock: noContentionLock,
crypto,
policy: runtimePolicy({
providerAttemptTimeoutMs: 5,
cleanupDeadlineMs: 25,
maxRetries: 0,
}),
now: () => 1_000,
random: () => 0,
sleep: async () => {},
});
const uploading = runtime.upload({
uploadKey: "upload_key_raw",
purpose: "attachment",
mediaType: "application/octet-stream",
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
signal: activeSignal,
});
await vi.waitFor(() => expect(releaseProvider).toBeDefined());
// The wrapper has already given up on the attempt.
await uploading;
const disposed = await runtime.dispose();
expect(disposed.ok).toBe(false);
if (!disposed.ok) {
expect(disposed.error.code).toBe("UNAVAILABLE");
expect(disposed.error.recovery).toBe("RESUME");
}
// The store stays open while something could still write a checkpoint.
expect(closeStore).not.toHaveBeenCalled();
releaseProvider?.();
});
it("reports a drained runtime once the raw provider settles", async () => {
const harness = createControlHarness();
const checkpoints = new MemoryCheckpointStore();
let releaseProvider: (() => void) | undefined;
harness.control.createSession = () =>
new Promise((resolve) => {
releaseProvider = () =>
resolve(
browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", {
retryable: true,
recovery: "RESUME",
}),
);
});
const runtime = createResumableUploadRuntime({
controlPlane: harness.control,
partExecutor: executorFor(harness),
checkpoints,
mutationLock: noContentionLock,
crypto,
policy: runtimePolicy({
providerAttemptTimeoutMs: 5,
cleanupDeadlineMs: 1_000,
maxRetries: 0,
}),
now: () => 1_000,
random: () => 0,
sleep: async () => {},
});
const uploading = runtime.upload({
uploadKey: "upload_key_raw_2",
purpose: "attachment",
mediaType: "application/octet-stream",
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
signal: activeSignal,
});
await vi.waitFor(() => expect(releaseProvider).toBeDefined());
await uploading;
const disposing = runtime.dispose();
releaseProvider?.();
await expect(disposing).resolves.toMatchObject({ ok: true });
expect(runtime.lifecycle()).toBe("CLOSED");
});
});
+13 -487
View File
@@ -32,319 +32,19 @@ import type {
} from "../../src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts"; } from "../../src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts";
import type { UploadMutationLock } from "../../src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts"; import type { UploadMutationLock } from "../../src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts";
type TestCapability = Readonly<{ id: string }>; import {
activeSignal,
const activeSignal = new AbortController().signal; byteStreamSource,
const noContentionLock: UploadMutationLock = Object.freeze({ createControlHarness,
async run<Value>( createMemoryCancellationPair,
_uploadKey: string, createSerialMutationLock,
_signal: AbortSignal, executorFor,
task: () => Promise<Value>, MemoryCheckpointStore,
): Promise<Value> { noContentionLock,
return await task(); rangeSource,
}, runtimePolicy,
}); type TestCapability,
} from "./resumable-upload-runtime-fixture.ts";
function createSerialMutationLock(): UploadMutationLock {
let tail = Promise.resolve();
return Object.freeze({
run<Value>(
_uploadKey: string,
signal: AbortSignal,
task: () => Promise<Value>,
): Promise<Value> {
const result = tail.then(async () => {
if (signal.aborted) {
throw new DOMException(
"The operation was aborted.",
"AbortError",
);
}
return await task();
});
tail = result.then(
() => undefined,
() => undefined,
);
return result;
},
});
}
function createMemoryCancellationPair(): readonly [
UploadCancellationChannel,
UploadCancellationChannel,
] {
const listeners = [
new Set<UploadCancellationListener>(),
new Set<UploadCancellationListener>(),
] as const;
const channels = listeners.map((ownListeners, ownIndex) => {
let closed = false;
return Object.freeze({
publish(uploadKey: string) {
if (closed) return false;
for (const [index, peerListeners] of listeners.entries()) {
if (index === ownIndex) continue;
for (const listener of [...peerListeners]) {
listener(uploadKey);
}
}
return true;
},
subscribe(listener: UploadCancellationListener) {
if (closed) throw new TypeError("closed");
ownListeners.add(listener);
return () => ownListeners.delete(listener);
},
close() {
closed = true;
ownListeners.clear();
},
});
});
return channels as unknown as readonly [
UploadCancellationChannel,
UploadCancellationChannel,
];
}
class MemoryCheckpointStore implements ResumableUploadCheckpointStore {
readonly rows = new Map<string, ResumableUploadCheckpoint>();
closed = false;
async read(
uploadKey: string,
): Promise<BrowserDataResult<ResumableUploadCheckpoint | null>> {
return browserDataSuccess(
structuredClone(this.rows.get(uploadKey) ?? null),
);
}
async compareAndSwap(
input: Parameters<
ResumableUploadCheckpointStore["compareAndSwap"]
>[0],
): Promise<BrowserDataResult<ResumableUploadCheckpoint>> {
const current = this.rows.get(input.checkpoint.uploadKey);
if (
(input.expectedRevision === null && current) ||
(input.expectedRevision !== null &&
current?.revision !== input.expectedRevision)
) {
return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", {
recovery: "RECONCILE",
});
}
const snapshot = structuredClone(input.checkpoint);
this.rows.set(snapshot.uploadKey, snapshot);
return browserDataSuccess(snapshot);
}
async remove(
input: Parameters<ResumableUploadCheckpointStore["remove"]>[0],
): Promise<BrowserDataResult<void>> {
const current = this.rows.get(input.uploadKey);
if (current?.revision !== input.expectedRevision) {
return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", {
recovery: "RECONCILE",
});
}
this.rows.delete(input.uploadKey);
return browserDataSuccess(undefined);
}
close(): void {
this.closed = true;
}
}
function rangeSource(bytes: Uint8Array): ResumableUploadSource {
return Object.freeze({
kind: "RANGE_READER" as const,
reader: Object.freeze({
byteLength: bytes.byteLength,
async readRange(input: Readonly<{
offset: number;
length: number;
signal: AbortSignal;
}>) {
if (input.signal.aborted) {
return browserDataFailure("ABORTED", "FILE_READ");
}
return browserDataSuccess(
bytes.slice(input.offset, input.offset + input.length),
);
},
}),
});
}
function byteStreamSource(bytes: Uint8Array): ResumableUploadSource {
return Object.freeze({
kind: "FILE_BYTE_SOURCE" as const,
bytes: Object.freeze({
byteLength: bytes.byteLength,
async *stream(signal: AbortSignal) {
if (signal.aborted) {
yield browserDataFailure("ABORTED", "FILE_READ");
return;
}
yield browserDataSuccess(bytes.slice(0, 3));
yield browserDataSuccess(bytes.slice(3));
},
}),
});
}
function runtimePolicy(
overrides: Partial<
Parameters<typeof resolveResumableUploadRuntimePolicy>[0]
> = {},
) {
return {
partSizeBytes: 4,
maxFileBytes: 100,
maxPartCount: 25,
maxConcurrency: 3,
maxInFlightBytes: 48,
partBufferCopyFactor: 4,
maxSourceChunkBytes: 8,
maxRetries: 2,
retryBaseDelayMs: 1,
retryMaxDelayMs: 10,
maxRetryAfterMs: 100,
capabilityRefreshSkewMs: 5,
maxSessionLifetimeMs: 10_000,
providerAttemptTimeoutMs: 100,
...overrides,
};
}
type ControlHarness = Readonly<{
control: ResumableUploadControlPlane<TestCapability>;
accepted: Map<number, UploadPartReceipt>;
issued: ReturnType<typeof vi.fn>;
completedParts: UploadPartReceipt[][];
getSession(): UploadSession | null;
}>;
function createControlHarness(options: Readonly<{
now?: number;
serverMaxConcurrency?: number;
sessionId?: (createIndex: number) => string;
statusParts?: (
session: UploadSession,
accepted: Map<number, UploadPartReceipt>,
) => readonly UploadPartReceipt[];
issueCapability?: (
input: Parameters<
ResumableUploadControlPlane<TestCapability>["issuePartCapability"]
>[0],
callIndex: number,
) => UploadProviderResult<Readonly<{
capability: TestCapability;
uploadBindingSha256: string;
expiresAtEpochMs: number;
}>>;
}> = {}): ControlHarness {
const now = options.now ?? 1_000;
const accepted = new Map<number, UploadPartReceipt>();
const completedParts: UploadPartReceipt[][] = [];
let session: UploadSession | null = null;
let createCount = 0;
let issueCount = 0;
const issued = vi.fn();
const control: ResumableUploadControlPlane<TestCapability> = {
async createSession(input) {
createCount += 1;
session = Object.freeze({
protocol: RESUMABLE_UPLOAD_PROTOCOL,
sessionId:
options.sessionId?.(createCount) ?? "session_01",
requestBindingSha256: input.requestBindingSha256,
fingerprint: input.fingerprint,
partSizeBytes: input.requestedPartSizeBytes,
partCount: input.fingerprint.partCount,
maxConcurrency: options.serverMaxConcurrency ?? 2,
expiresAtEpochMs: now + 5_000,
});
return browserDataSuccess(session);
},
async getStatus() {
if (!session) {
return browserDataFailure("NOT_FOUND", "UPLOAD_RECONCILE");
}
const parts =
options.statusParts?.(session, accepted) ??
[...accepted.values()].sort(
(left, right) => left.partNumber - right.partNumber,
);
return browserDataSuccess({
state: "ACTIVE",
session,
acceptedParts: parts,
});
},
async issuePartCapability(input) {
issueCount += 1;
issued(input);
return (
options.issueCapability?.(input, issueCount) ??
browserDataSuccess({
capability: Object.freeze({ id: `cap-${issueCount}` }),
uploadBindingSha256: input.uploadBindingSha256,
expiresAtEpochMs: now + 4_000,
})
);
},
async complete(input) {
completedParts.push([...input.orderedParts]);
return browserDataSuccess({
state: "QUARANTINED",
protocol: RESUMABLE_UPLOAD_PROTOCOL,
sessionId: input.sessionId,
requestBindingSha256: input.requestBindingSha256,
fingerprint: input.fingerprint,
resourceId: "resource_01",
});
},
async abort() {
return browserDataSuccess({ state: "ABORTED" });
},
};
return {
control,
accepted,
issued,
completedParts,
getSession: () => session,
};
}
function executorFor(
harness: ControlHarness,
options: Readonly<{
delay?: () => Promise<void>;
onActive?: (active: number) => void;
}> = {},
): UploadPartExecutor<TestCapability> {
let active = 0;
return {
async uploadPart(input) {
active += 1;
options.onActive?.(active);
await options.delay?.();
active -= 1;
const receipt = Object.freeze({
...input.part,
receiptToken: `etag-part-${input.part.partNumber}`,
});
harness.accepted.set(input.part.partNumber, receipt);
return browserDataSuccess(receipt);
},
};
}
describe("production resumable upload runtime", () => { describe("production resumable upload runtime", () => {
it("disposes through one drain that proves quiescence", async () => { it("disposes through one drain that proves quiescence", async () => {
@@ -1280,177 +980,3 @@ describe("production resumable upload runtime", () => {
} }
}); });
}); });
/**
* TR-RR-06. A non-cooperative mutation lock or provider must not make teardown
* unbounded: `dispose()` bounds its drain and reports honestly when the runtime
* is still CLOSING, and an abort is admitted physical work it cannot step over.
*/
describe("TR-RR-06 bounded resumable teardown", () => {
it("reports an unproved drain instead of waiting forever", async () => {
const checkpoints = new MemoryCheckpointStore();
const harness = createControlHarness();
const runtime = createResumableUploadRuntime({
controlPlane: harness.control,
partExecutor: executorFor(harness, { delay: async () => {} }),
checkpoints,
// A lock that never grants: dispose must still be bounded.
mutationLock: Object.freeze({
async run<Value>(): Promise<Value> {
return await new Promise<never>(() => {});
},
}),
crypto,
policy: runtimePolicy({ cleanupDeadlineMs: 20 }),
now: () => 1_000,
random: () => 0,
sleep: async () => {},
});
void runtime.upload({
uploadKey: "upload_key_hung",
purpose: "attachment",
mediaType: "application/octet-stream",
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
signal: activeSignal,
});
await Promise.resolve();
await Promise.resolve();
const disposed = await runtime.dispose();
expect(disposed.ok).toBe(false);
// Still CLOSING: physical work the caller must not treat as finished.
expect(runtime.lifecycle()).toBe("CLOSING");
expect(checkpoints.closed).toBe(false);
});
it("closes once every admitted operation settles", async () => {
const checkpoints = new MemoryCheckpointStore();
const harness = createControlHarness();
const runtime = createResumableUploadRuntime({
controlPlane: harness.control,
partExecutor: executorFor(harness, { delay: async () => {} }),
checkpoints,
mutationLock: noContentionLock,
crypto,
policy: runtimePolicy({ cleanupDeadlineMs: 200 }),
now: () => 1_000,
random: () => 0,
sleep: async () => {},
});
const disposed = await runtime.dispose();
expect(disposed).toMatchObject({ ok: true });
expect(runtime.lifecycle()).toBe("CLOSED");
expect(checkpoints.closed).toBe(true);
});
});
/**
* TR-04. `dispose()` proved quiescence from the wrapper registry only. A
* provider that ignored its attempt deadline let the wrapper settle first and
* leave the set empty, so teardown reported a drained runtime and closed the
* checkpoint store while the provider was still running.
*/
describe("TR-04 teardown waits for raw provider work, not only its wrapper", () => {
it("refuses to report a drained runtime while a provider is still running", async () => {
const harness = createControlHarness();
const checkpoints = new MemoryCheckpointStore();
const closeStore = vi.spyOn(checkpoints, "close");
let releaseProvider: (() => void) | undefined;
// Ignores the attempt signal entirely and outlives its own deadline.
harness.control.createSession = () =>
new Promise((resolve) => {
releaseProvider = () =>
resolve(
browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", {
retryable: true,
recovery: "RESUME",
}),
);
});
const runtime = createResumableUploadRuntime({
controlPlane: harness.control,
partExecutor: executorFor(harness),
checkpoints,
mutationLock: noContentionLock,
crypto,
policy: runtimePolicy({
providerAttemptTimeoutMs: 5,
cleanupDeadlineMs: 25,
maxRetries: 0,
}),
now: () => 1_000,
random: () => 0,
sleep: async () => {},
});
const uploading = runtime.upload({
uploadKey: "upload_key_raw",
purpose: "attachment",
mediaType: "application/octet-stream",
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
signal: activeSignal,
});
await vi.waitFor(() => expect(releaseProvider).toBeDefined());
// The wrapper has already given up on the attempt.
await uploading;
const disposed = await runtime.dispose();
expect(disposed.ok).toBe(false);
if (!disposed.ok) {
expect(disposed.error.code).toBe("UNAVAILABLE");
expect(disposed.error.recovery).toBe("RESUME");
}
// The store stays open while something could still write a checkpoint.
expect(closeStore).not.toHaveBeenCalled();
releaseProvider?.();
});
it("reports a drained runtime once the raw provider settles", async () => {
const harness = createControlHarness();
const checkpoints = new MemoryCheckpointStore();
let releaseProvider: (() => void) | undefined;
harness.control.createSession = () =>
new Promise((resolve) => {
releaseProvider = () =>
resolve(
browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", {
retryable: true,
recovery: "RESUME",
}),
);
});
const runtime = createResumableUploadRuntime({
controlPlane: harness.control,
partExecutor: executorFor(harness),
checkpoints,
mutationLock: noContentionLock,
crypto,
policy: runtimePolicy({
providerAttemptTimeoutMs: 5,
cleanupDeadlineMs: 1_000,
maxRetries: 0,
}),
now: () => 1_000,
random: () => 0,
sleep: async () => {},
});
const uploading = runtime.upload({
uploadKey: "upload_key_raw_2",
purpose: "attachment",
mediaType: "application/octet-stream",
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
signal: activeSignal,
});
await vi.waitFor(() => expect(releaseProvider).toBeDefined());
await uploading;
const disposing = runtime.dispose();
releaseProvider?.();
await expect(disposing).resolves.toMatchObject({ ok: true });
expect(runtime.lifecycle()).toBe("CLOSED");
});
});
+541
View File
@@ -0,0 +1,541 @@
import {
createHash,
generateKeyPairSync,
sign,
type KeyObject,
} from "node:crypto";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { localEvidenceAssessmentArtifactSchema } from "../../scripts/contracts/release-artifacts.ts";
import { PROMOTED_FILE_NAMES } from "../../scripts/contracts/promotion-artifacts.ts";
import {
providerEvidenceSignaturePayload,
trustPolicySha256,
} from "../../scripts/lib/provider-evidence.ts";
import {
LOCAL_EVIDENCE_ASSESSMENT_PATH,
distSha256,
type ReleaseCandidateManifest,
} from "../../scripts/lib/release-candidate.ts";
import { supplyChainDigest } from "../../scripts/lib/supply-chain.ts";
export const PROCESS_HEAVY_TIMEOUT_MS = 30_000;
export const digest = (value: string): string =>
createHash("sha256").update(value).digest("hex");
export const digestBytes = (value: Buffer): string =>
createHash("sha256").update(value).digest("hex");
export function passingAssessment(): any {
return {
schemaVersion: 1 as const,
artifactType: "local-evidence-assessment" as const,
generatedAt: "2026-08-02T00:00:00.000Z",
status: "PASS" as const,
verifier: {
id: "clean-architecture-frontend-template/local-evidence-verifier",
version: "1",
sourceSha256: digest("verifier source"),
},
source: {
revision: "a".repeat(40),
sourceSetSha256: digest("source set"),
},
candidate: {
distSha256: digest("dist"),
lockfileSha256: digest("lockfile"),
sbomSha256: digest("sbom"),
},
secretScan: {
policySha256: digest("secret policy"),
sarifSha256: digest("secret sarif"),
scanInputSha256: digest("secret scan input"),
},
policyInputs: [
{
path: "config/security/dependency-policy.json",
bytes: 3,
sha256: digest("{}\n"),
},
],
evidenceInputs: [
{ path: "pnpm-lock.yaml", bytes: 9, sha256: digest("lockfile\n") },
],
checks: {
release: "PASS" as const,
supplyChain: "PASS" as const,
dependencyPolicy: "PASS" as const,
licensePolicy: "PASS" as const,
vulnerabilityPolicy: "PASS" as const,
secretScan: "PASS" as const,
},
failures: [] as string[],
};
}
export function providerExpectedContext() {
return {
run: { id: "run-42", attempt: 1 },
source: { revision: "b".repeat(40), sourceSetSha256: digest("provider source") },
candidate: {
archiveSha256: digest("archive"),
bundleSha256: digest("bundle"),
distSha256: digest("provider dist"),
lockfileSha256: digest("provider lockfile"),
},
secretScanAttestation: {
status: "PASS" as const,
localEvidenceAssessmentSha256: digest("provider assessment"),
sourceSetSha256: digest("provider source"),
policySha256: digest("provider secret policy"),
sarifSha256: digest("provider secret sarif"),
scanInputSha256: digest("provider secret input"),
},
} as const;
}
export function privatePromotionFiles() {
return PROMOTED_FILE_NAMES.map((name) => {
const bytes = Buffer.from(`${name}\n`);
return { name, bytes, sha256: digestBytes(bytes) };
});
}
export function syntheticSignedPromotionBundle() {
const vulnerabilityKeys = generateKeyPairSync("ed25519");
const provenanceKeys = generateKeyPairSync("ed25519");
const archiveBytes = Buffer.from("synthetic signed replay archive\n");
const run = { id: "signed-run", attempt: 1 } as const;
const source = {
revision: "a".repeat(40),
sourceSetSha256: digest("synthetic-source-set"),
} as const;
const candidate = {
archiveSha256: digestBytes(archiveBytes),
bundleSha256: digest("synthetic-bundle"),
distSha256: digest("synthetic-dist"),
lockfileSha256: digest("synthetic-lock"),
} as const;
const vulnerability = signedProviderV2(
{
schemaVersion: 2,
evidenceType: "vulnerability-report",
provider: "synthetic-vulnerability-provider",
issuedAt: "2026-08-02T01:00:00.000Z",
expiresAt: "2026-08-02T02:00:00.000Z",
run: { ...run, invocationNonce: "1".repeat(64) },
source,
candidate,
secretScanAttestation: {
status: "PASS",
localEvidenceAssessmentSha256: digest("synthetic-assessment"),
sourceSetSha256: source.sourceSetSha256,
policySha256: digest("synthetic-policy"),
sarifSha256: digest("synthetic-sarif"),
scanInputSha256: digest("synthetic-scan-input"),
},
findings: [],
},
"synthetic-vulnerability",
vulnerabilityKeys.publicKey,
vulnerabilityKeys.privateKey,
);
const provenance = signedProviderV2(
{
schemaVersion: 2,
evidenceType: "provenance-attestation",
provider: "synthetic-provenance-provider",
signer: "synthetic-signer",
issuedAt: "2026-08-02T01:00:00.000Z",
expiresAt: "2026-08-02T02:00:00.000Z",
run: { ...run, invocationNonce: "2".repeat(64) },
source,
candidate,
subject: { name: "dist", digest: { sha256: candidate.distSha256 } },
},
"synthetic-provenance",
provenanceKeys.publicKey,
provenanceKeys.privateKey,
);
const vulnerabilityBytes = Buffer.from(`${JSON.stringify(vulnerability)}\n`);
const provenanceBytes = Buffer.from(`${JSON.stringify(provenance)}\n`);
const vulnerabilityTrust = trust("synthetic-vulnerability", vulnerabilityKeys.publicKey);
const provenanceTrust = trust("synthetic-provenance", provenanceKeys.publicKey);
const providerEvidence = {
vulnerabilityReportSha256: digestBytes(vulnerabilityBytes),
provenanceAttestationSha256: digestBytes(provenanceBytes),
vulnerabilityInvocationNonce: "1".repeat(64),
provenanceInvocationNonce: "2".repeat(64),
vulnerabilityKeyId: vulnerabilityTrust.keyId,
vulnerabilityKeyFingerprint: vulnerabilityTrust.publicKeyFingerprint,
provenanceKeyId: provenanceTrust.keyId,
provenanceKeyFingerprint: provenanceTrust.publicKeyFingerprint,
secretScanAttestation: vulnerability.secretScanAttestation,
};
const common = {
schemaVersion: 3,
verifiedAt: "2026-08-02T01:00:00.000Z",
status: "PASS",
verifier: {
id: "clean-architecture-frontend-template/promotion-verifier",
version: "3",
},
run,
source,
candidate,
providerEvidence,
trustPolicySha256: trustPolicySha256({ vulnerabilityTrust, provenanceTrust }),
failures: [],
};
const providerBytes = Buffer.from(
`${JSON.stringify({
...common,
artifactType: "provider-verification",
vulnerabilityStatus: "PASS",
provenanceAttestationStatus: "PASS",
}, null, 2)}\n`,
);
const promotionBytes = Buffer.from(
`${JSON.stringify({
...common,
artifactType: "promotion-verification",
localEvidenceStatus: "PASS",
localEvidenceAssessmentSha256: digest("synthetic-assessment"),
providerVerificationSha256: digestBytes(providerBytes),
}, null, 2)}\n`,
);
return {
files: {
"release-candidate.tar.gz": archiveBytes,
"vulnerability-report.json": vulnerabilityBytes,
"provenance-attestation.json": provenanceBytes,
"provider-verification.json": providerBytes,
"promotion-verification.json": promotionBytes,
},
verification: {
vulnerabilityTrust,
provenanceTrust,
nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"),
expected: {
run,
sourceRevision: source.revision,
sourceSetSha256: source.sourceSetSha256,
archiveSha256: candidate.archiveSha256,
bundleSha256: candidate.bundleSha256,
distSha256: candidate.distSha256,
lockfileSha256: candidate.lockfileSha256,
},
},
vulnerabilityPem: vulnerabilityKeys.publicKey.export({ type: "spki", format: "pem" }),
provenancePem: provenanceKeys.publicKey.export({ type: "spki", format: "pem" }),
};
}
export function fingerprint(publicKey: KeyObject): string {
return `sha256:${createHash("sha256")
.update(publicKey.export({ type: "spki", format: "der" }))
.digest("hex")}`;
}
export function trust(keyId: string, publicKey: KeyObject) {
return { keyId, publicKey, publicKeyFingerprint: fingerprint(publicKey) };
}
export function signedProviderV2(
unsigned: Record<string, unknown>,
keyId: string,
publicKey: KeyObject,
privateKey: KeyObject,
fingerprintOverride?: string,
): Record<string, any> {
const { signature: existingSignature, ...payload } = unsigned;
const value = {
...payload,
signature: {
algorithm: "Ed25519" as const,
keyId,
publicKeyFingerprint:
fingerprintOverride ??
(existingSignature && typeof existingSignature === "object" &&
"publicKeyFingerprint" in existingSignature
? String(existingSignature.publicKeyFingerprint)
: fingerprint(publicKey)),
value: "",
},
};
value.signature.value = sign(
null,
providerEvidenceSignaturePayload(value),
privateKey,
).toString("base64");
return value;
}
export function providerUnsigned(
kind: "vulnerability" | "provenance",
expected: ReturnType<typeof providerExpectedContext>,
): Record<string, any> {
const common = {
source: expected.source,
candidate: expected.candidate,
schemaVersion: 2,
evidenceType:
kind === "vulnerability"
? "vulnerability-report"
: "provenance-attestation",
provider: `fixture-${kind}`,
issuedAt: "2026-08-02T01:00:00.000Z",
expiresAt: "2026-08-02T02:00:00.000Z",
run: {
...expected.run,
invocationNonce: kind === "vulnerability" ? "1".repeat(64) : "2".repeat(64),
},
};
return kind === "vulnerability"
? {
...common,
secretScanAttestation: expected.secretScanAttestation,
findings: [],
}
: {
...common,
signer: "fixture-workload",
subject: {
name: "dist",
digest: { sha256: expected.candidate.distSha256 },
},
};
}
export async function createArchivedAssessmentFixture(): Promise<{
root: string;
manifest: ReleaseCandidateManifest;
assessmentSha256: string;
}> {
const root = await mkdtemp(path.join(tmpdir(), "archived-assessment-"));
const sourceRevision = "a".repeat(40);
const sourceSetSha256 = digest("source set");
const releaseManifestBytes = Buffer.from(
`${JSON.stringify({
schemaVersion: 1,
appVersion: "1.0.0",
buildId: "build-1",
commitSha: sourceRevision,
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: digest("vite manifest"),
releaseId: "release-1",
builtAt: "2026-08-02T00:00:00.000Z",
routeChunks: { home: "assets/home.js" },
})}\n`,
);
const distInputs = [
{ path: "dist/app.js", bytes: Buffer.byteLength("app\n"), sha256: digest("app\n"), gzipBytes: 0 },
{
path: "dist/release-manifest.json",
bytes: releaseManifestBytes.byteLength,
sha256: digestBytes(releaseManifestBytes),
gzipBytes: 0,
},
];
const candidateDist = distSha256(distInputs);
const sbomBytes = Buffer.from(
`${JSON.stringify({
bomFormat: "CycloneDX",
specVersion: "1.6",
serialNumber: "urn:uuid:00000000-0000-4000-8000-000000000001",
version: 1,
metadata: {
component: { type: "application", name: "fixture", version: "1.0.0" },
properties: [],
},
components: [],
dependencies: [],
})}\n`,
);
const sbomSha256 = digestBytes(sbomBytes);
const lockfileBytes = Buffer.from("lockfile\n");
const lockfileDigest = digestBytes(lockfileBytes);
const buildManifest = {
schemaVersion: 1,
buildId: "build-1",
commitSha: sourceRevision,
releaseId: "release-1",
moduleInventoryHash: digest("module inventory"),
generatedAt: "2026-08-02T00:00:00.000Z",
buildContext: {
nodeVersion: "v24.0.0",
packageManagerVersion: "11.0.0",
runnerImage: "linux-x64",
sourceDateEpoch: "1785638400",
},
outputs: {
directory: "dist",
viteManifest: "dist/.vite/manifest.json",
moduleInventory: "artifacts/quality/vite-module-inventory.json",
routeChunks: { home: "assets/home.js" },
runtimeConfigSchema: "dist/runtime-config.schema.json",
},
};
const provenance = {
_type: "https://in-toto.io/Statement/v1",
subject: [{ name: "dist", digest: { sha256: candidateDist } }],
predicateType: "https://slsa.dev/provenance/v1",
predicate: {
buildDefinition: {
buildType: "https://vite.dev/build/v1",
externalParameters: {},
internalParameters: {},
resolvedDependencies: [
{ uri: "pnpm-lock.yaml", digest: { sha256: lockfileDigest } },
],
},
runDetails: {
builder: { id: "fixture-builder" },
metadata: { invocationId: "LOCAL_UNSIGNED" },
},
materials: { lockfileSha256: lockfileDigest, sourceSetSha256, sbomSha256 },
},
};
const supplyVerification = {
schemaVersion: 1,
localStatus: "PASS",
promotionStatus: "FAIL_UNVERIFIED",
lockfileSha256: lockfileDigest,
sourceSetSha256,
distSha256: candidateDist,
sbomSha256,
dependencyDiff: { added: [], removed: [], changed: [], upgrades: [] },
highRiskReview: [],
vulnerabilityStatus: "FAIL_UNVERIFIED",
provenanceAttestationStatus: "FAIL_UNVERIFIED",
failures: [],
};
const members = new Map<string, Buffer>([
["dist/app.js", Buffer.from("app\n")],
["dist/release-manifest.json", releaseManifestBytes],
["pnpm-lock.yaml", lockfileBytes],
["artifacts/release/build-manifest.json", Buffer.from(`${JSON.stringify(buildManifest)}\n`)],
["artifacts/release/provenance.json", Buffer.from(`${JSON.stringify(provenance)}\n`)],
[
"artifacts/security/supply-chain-verification.json",
Buffer.from(`${JSON.stringify(supplyVerification)}\n`),
],
["artifacts/release/sbom.cdx.json", sbomBytes],
]);
const evidenceInputs = [...members.entries()]
.map(([memberPath, bytes]) => ({
path: memberPath,
bytes: bytes.byteLength,
sha256: digestBytes(bytes),
}))
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
const policyPaths = [
"config/security/dependency-baseline.approval.json",
"config/security/dependency-baseline.json",
"config/security/dependency-change-evidence.json",
"config/security/dependency-policy.json",
"config/security/secret-scan-policy.json",
"config/security/vulnerability-exceptions.json",
"config/security/vulnerability-policy.json",
"schemas/artifacts/build-manifest.schema.json",
"schemas/artifacts/dependency-inventory.schema.json",
"schemas/artifacts/supply-chain-verification.schema.json",
"scripts/contracts/release-artifacts.ts",
"scripts/create-release-candidate.ts",
"scripts/generate-supply-chain.ts",
"scripts/lib/build-manifest-outputs.ts",
"scripts/lib/json-schema.ts",
"scripts/lib/local-policy-evidence.ts",
"scripts/lib/local-release-evidence.ts",
"scripts/lib/release-candidate.ts",
"scripts/lib/release-input-evidence.ts",
"scripts/lib/release-runtime-coherence.ts",
"scripts/lib/repository-file-inventory.ts",
"scripts/lib/secret-scan-evaluator.ts",
"scripts/lib/secret-scan-policy.ts",
"scripts/lib/secret-scan.ts",
"scripts/lib/supply-chain.ts",
"scripts/lib/validated-json-artifact.ts",
"src/contracts/release-artifacts.ts",
"src/features/installed-contract-contributions.ts",
"src/features/installed-feature-contracts.ts",
];
const sbomRow = evidenceInputs.find(
({ path: memberPath }) => memberPath === "artifacts/release/sbom.cdx.json",
)!;
const policyInputs = policyPaths.map((policyPath) => ({
path: policyPath,
bytes: 2,
sha256: digest(`policy:${policyPath}`),
}));
const verifierPaths = new Set([
"scripts/contracts/release-artifacts.ts",
"scripts/create-release-candidate.ts",
"scripts/generate-supply-chain.ts",
"scripts/lib/build-manifest-outputs.ts",
"scripts/lib/json-schema.ts",
"scripts/lib/local-policy-evidence.ts",
"scripts/lib/local-release-evidence.ts",
"scripts/lib/release-candidate.ts",
"scripts/lib/release-input-evidence.ts",
"scripts/lib/release-runtime-coherence.ts",
"scripts/lib/repository-file-inventory.ts",
"scripts/lib/secret-scan-evaluator.ts",
"scripts/lib/secret-scan-policy.ts",
"scripts/lib/secret-scan.ts",
"scripts/lib/supply-chain.ts",
"scripts/lib/validated-json-artifact.ts",
"src/contracts/release-artifacts.ts",
"src/features/installed-contract-contributions.ts",
"src/features/installed-feature-contracts.ts",
]);
const assessment = localEvidenceAssessmentArtifactSchema.parse({
...passingAssessment(),
verifier: {
id: "clean-architecture-frontend-template/local-evidence-verifier",
version: "1",
sourceSha256: supplyChainDigest(
policyInputs.filter(({ path: policyPath }) => verifierPaths.has(policyPath)),
),
},
source: { revision: sourceRevision, sourceSetSha256 },
candidate: {
distSha256: candidateDist,
lockfileSha256: evidenceInputs.find(({ path: memberPath }) => memberPath === "pnpm-lock.yaml")!
.sha256,
sbomSha256: sbomRow.sha256,
},
policyInputs,
evidenceInputs,
});
const assessmentBytes = Buffer.from(`${JSON.stringify(assessment)}\n`);
members.set(LOCAL_EVIDENCE_ASSESSMENT_PATH, assessmentBytes);
for (const [memberPath, bytes] of members) {
await mkdir(path.dirname(path.join(root, memberPath)), { recursive: true });
await writeFile(path.join(root, memberPath), bytes);
}
const files = [...members.entries()]
.map(([memberPath, bytes]) => ({
path: memberPath,
bytes: bytes.byteLength,
sha256: digestBytes(bytes),
}))
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
const manifest: ReleaseCandidateManifest = {
schemaVersion: 1,
distSha256: assessment.candidate.distSha256,
lockfileSha256: assessment.candidate.lockfileSha256,
bundleSha256: supplyChainDigest(files),
files,
};
await mkdir(path.join(root, "artifacts/release"), { recursive: true });
await writeFile(
path.join(root, "artifacts/release/release-candidate.json"),
`${JSON.stringify(manifest)}\n`,
);
return { root, manifest, assessmentSha256: digestBytes(assessmentBytes) };
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,370 @@
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
chmod,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rename,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import { PROMOTED_FILE_NAMES } from "../../scripts/contracts/promotion-artifacts.ts";
import { publishPrivatePromotionStaging } from "../../scripts/lib/promotion-stager.ts";
import { verifyExactPromotionBundle } from "../../scripts/lib/exact-promotion-bundle.ts";
import {
PROCESS_HEAVY_TIMEOUT_MS,
privatePromotionFiles,
syntheticSignedPromotionBundle,
} from "./security-followup-fixture.ts";
describe("security private promotion staging contracts", () => {
it("forces exact private staging modes in an isolated child with umask 077", async () => {
const root = await mkdtemp(path.join(tmpdir(), "promotion-umask-"));
try {
const stagerUrl = pathToFileURL(
path.join(process.cwd(), "scripts/lib/promotion-stager.ts"),
).href;
const contractsUrl = pathToFileURL(
path.join(process.cwd(), "scripts/contracts/promotion-artifacts.ts"),
).href;
const childPath = path.join(root, "umask-child.mjs");
const resultPath = path.join(root, "result.json");
await writeFile(resultPath, "{}\n", { mode: 0o600 });
await writeFile(
childPath,
[
`import { lstat, writeFile } from "node:fs/promises";`,
`import path from "node:path";`,
`import { createHash } from "node:crypto";`,
`import { cleanupFinalizedPromotion, publishPrivatePromotionStaging } from ${JSON.stringify(stagerUrl)};`,
`import { PROMOTED_FILE_NAMES } from ${JSON.stringify(contractsUrl)};`,
`process.umask(Number.parseInt(process.argv[2], 8));`,
`const runnerTempRoot = process.argv[3];`,
`const files = PROMOTED_FILE_NAMES.map((name) => { const bytes = Buffer.from(name); return { name, bytes, sha256: createHash("sha256").update(bytes).digest("hex") }; });`,
`const finalized = await publishPrivatePromotionStaging(runnerTempRoot, { id: "umask", attempt: 1 }, files, () => Buffer.alloc(16, 1));`,
`const directoryMode = (await lstat(finalized.stagingRoot)).mode & 0o777;`,
`const fileModes = await Promise.all(PROMOTED_FILE_NAMES.map(async (name) => (await lstat(path.join(finalized.stagingRoot, name))).mode & 0o777));`,
`await cleanupFinalizedPromotion({ runnerTempRoot, stagingRoot: finalized.stagingRoot, cleanupToken: finalized.cleanupToken, runnerTempIdentity: finalized.runnerTempIdentity, stagingIdentity: finalized.stagingIdentity });`,
`await writeFile(process.argv[4], JSON.stringify({ directoryMode, fileModes }));`,
].join("\n"),
);
const child = spawnSync(process.execPath, [childPath, "077", root, resultPath], {
cwd: root,
encoding: "utf8",
timeout: 30_000,
});
expect(child.status, `${child.stdout}\n${child.stderr}`).toBe(0);
expect(JSON.parse(await readFile(resultPath, "utf8"))).toEqual({
directoryMode: 0o700,
fileModes: [0o400, 0o400, 0o400, 0o400, 0o400],
});
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("rejects a staged file unlinked and recreated after its original write", async () => {
const root = await mkdtemp(path.join(tmpdir(), "promotion-seal-recreate-"));
const files = privatePromotionFiles();
const token = `promotion-seal-1-${"11".repeat(16)}`;
try {
await expect(
publishPrivatePromotionStaging(
root,
{ id: "seal", attempt: 1 },
files,
() => Buffer.alloc(16, 0x11),
undefined,
async (name) => {
if (name !== PROMOTED_FILE_NAMES.at(-1)) return;
const first = path.join(root, token, PROMOTED_FILE_NAMES[0]);
await rm(first);
await writeFile(first, "replacement bytes\n", { mode: 0o400 });
},
),
).rejects.toThrow(/staged.*digest|inode|seal/u);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("rejects staged mode drift before returning the upload root", async () => {
const root = await mkdtemp(path.join(tmpdir(), "promotion-seal-mode-"));
const files = privatePromotionFiles();
const token = `promotion-seal-1-${"12".repeat(16)}`;
try {
await expect(
publishPrivatePromotionStaging(
root,
{ id: "seal", attempt: 1 },
files,
() => Buffer.alloc(16, 0x12),
undefined,
async (name) => {
if (name === PROMOTED_FILE_NAMES.at(-1)) {
await chmod(path.join(root, token, PROMOTED_FILE_NAMES[0]), 0o600);
}
},
),
).rejects.toThrow(/mode.*0400|staged.*mode|seal/u);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("rejects staging leaf replacement between mkdir and descriptor open", async () => {
const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-replace-"));
const files = privatePromotionFiles();
const token = `promotion-preopen-1-${"13".repeat(16)}`;
const displaced = path.join(root, `${token}-displaced`);
const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]);
try {
await expect(
publishPrivatePromotionStaging(
root,
{ id: "preopen", attempt: 1 },
files,
() => Buffer.alloc(16, 0x13),
undefined,
undefined,
undefined,
async (stagingRoot) => {
await rename(stagingRoot, displaced);
await mkdir(stagingRoot, { mode: 0o700 });
await writeFile(replacementCanary, "external replacement canary\n");
},
),
).rejects.toThrow(/staging leaf.*changed|mkdir.*open|identity/u);
await expect(readFile(replacementCanary, "utf8")).resolves.toBe(
"external replacement canary\n",
);
await expect(readdir(displaced)).resolves.toEqual([]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("does not scan a crowded parent to recover an unverified pre-open leaf", async () => {
const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-bounded-"));
const files = privatePromotionFiles();
const token = `promotion-preopen-bound-1-${"14".repeat(16)}`;
const displaced = path.join(root, `${token}-displaced`);
const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]);
try {
for (let offset = 0; offset < 4_097; offset += 128) {
await Promise.all(
Array.from(
{ length: Math.min(128, 4_097 - offset) },
(_, index) =>
mkdir(
path.join(
root,
`noise-${String(offset + index).padStart(4, "0")}`,
),
),
),
);
}
let failure: unknown;
try {
await publishPrivatePromotionStaging(
root,
{ id: "preopen-bound", attempt: 1 },
files,
() => Buffer.alloc(16, 0x14),
undefined,
undefined,
undefined,
async (stagingRoot) => {
await rename(stagingRoot, displaced);
await mkdir(stagingRoot, { mode: 0o700 });
await writeFile(replacementCanary, "external replacement canary\n");
},
);
} catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(Error);
expect(failure).not.toBeInstanceOf(AggregateError);
expect((failure as Error).message).toMatch(/staging leaf.*identity/i);
await expect(readdir(displaced)).resolves.toEqual([]);
await expect(readFile(replacementCanary, "utf8")).resolves.toBe(
"external replacement canary\n",
);
} finally {
await rm(root, { recursive: true, force: true });
}
}, 20_000);
it("leaves a non-empty moved original untouched after pre-open mismatch", async () => {
const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-nonempty-"));
const files = privatePromotionFiles();
const token = `promotion-preopen-nonempty-1-${"15".repeat(16)}`;
const displaced = path.join(root, `${token}-displaced`);
const ownedResidual = path.join(displaced, "owned-residual");
const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]);
try {
let failure: unknown;
try {
await publishPrivatePromotionStaging(
root,
{ id: "preopen-nonempty", attempt: 1 },
files,
() => Buffer.alloc(16, 0x15),
undefined,
undefined,
undefined,
async (stagingRoot) => {
await rename(stagingRoot, displaced);
await writeFile(ownedResidual, "owned residual\n");
await mkdir(stagingRoot, { mode: 0o700 });
await writeFile(replacementCanary, "external replacement canary\n");
},
);
} catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(Error);
expect(failure).not.toBeInstanceOf(AggregateError);
expect((failure as Error).message).toMatch(/staging leaf.*identity/i);
await expect(readFile(ownedResidual, "utf8")).resolves.toBe(
"owned residual\n",
);
await expect(readFile(replacementCanary, "utf8")).resolves.toBe(
"external replacement canary\n",
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("does not search outside the parent for a moved unverified original", async () => {
const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-missing-"));
const outside = await mkdtemp(path.join(tmpdir(), "promotion-preopen-moved-"));
const files = privatePromotionFiles();
const token = `promotion-preopen-missing-1-${"16".repeat(16)}`;
const displaced = path.join(outside, token);
const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]);
try {
let failure: unknown;
try {
await publishPrivatePromotionStaging(
root,
{ id: "preopen-missing", attempt: 1 },
files,
() => Buffer.alloc(16, 0x16),
undefined,
undefined,
undefined,
async (stagingRoot) => {
await rename(stagingRoot, displaced);
await mkdir(stagingRoot, { mode: 0o700 });
await writeFile(replacementCanary, "external replacement canary\n");
},
);
} catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(Error);
expect(failure).not.toBeInstanceOf(AggregateError);
expect((failure as Error).message).toMatch(/staging leaf.*identity/i);
await expect(lstat(displaced)).resolves.toEqual(
expect.objectContaining({ dev: expect.any(Number), ino: expect.any(Number) }),
);
await expect(readFile(replacementCanary, "utf8")).resolves.toBe(
"external replacement canary\n",
);
} finally {
await rm(root, { recursive: true, force: true });
await rm(outside, { recursive: true, force: true });
}
});
it("rejects a fresh signed exact-five bundle replayed under a different expected run", async () => {
const fixture = syntheticSignedPromotionBundle();
await expect(
verifyExactPromotionBundle(fixture.files, {
...fixture.verification,
expected: {
...fixture.verification.expected,
run: { id: "different-run", attempt: 1 },
},
}),
).rejects.toThrow(/external expected run.*mismatch|expected promotion run/u);
});
it("requires every external expected identity variable at the exact promotion CLI", async () => {
const fixture = syntheticSignedPromotionBundle();
const root = await mkdtemp(path.join(tmpdir(), "promotion-replay-cli-"));
const bundleRoot = path.join(root, "bundle");
try {
await mkdir(bundleRoot);
for (const [name, bytes] of Object.entries(fixture.files)) {
await writeFile(path.join(bundleRoot, name), bytes);
}
await writeFile(path.join(root, "vulnerability.pem"), fixture.vulnerabilityPem);
await writeFile(path.join(root, "provenance.pem"), fixture.provenancePem);
const cliPath = path.join(process.cwd(), "scripts/verify-exact-promotion-bundle.ts");
const baseEnvironment: NodeJS.ProcessEnv = {
...process.env,
PROMOTION_BUNDLE_ROOT: bundleRoot,
VULNERABILITY_PUBLIC_KEY_PATH: "vulnerability.pem",
VULNERABILITY_KEY_ID: "synthetic-vulnerability",
PROVENANCE_PUBLIC_KEY_PATH: "provenance.pem",
PROVENANCE_KEY_ID: "synthetic-provenance",
EXPECTED_PROMOTION_RUN_ID: fixture.verification.expected.run.id,
EXPECTED_PROMOTION_RUN_ATTEMPT: String(
fixture.verification.expected.run.attempt,
),
EXPECTED_PROMOTION_SOURCE_REVISION:
fixture.verification.expected.sourceRevision,
EXPECTED_PROMOTION_ARCHIVE_SHA256:
fixture.verification.expected.archiveSha256,
};
const requiredExpected = [
"EXPECTED_PROMOTION_RUN_ID",
"EXPECTED_PROMOTION_RUN_ATTEMPT",
"EXPECTED_PROMOTION_SOURCE_REVISION",
"EXPECTED_PROMOTION_ARCHIVE_SHA256",
] as const;
for (const missing of requiredExpected) {
const environment = { ...baseEnvironment };
delete environment[missing];
const result = spawnSync(process.execPath, [cliPath], {
cwd: root,
encoding: "utf8",
env: environment,
});
expect(result.status, missing).not.toBe(0);
expect(result.stderr, missing).toContain(
`exact promotion verification environment is missing ${missing}`,
);
}
for (const [name, value, diagnostic] of [
["EXPECTED_PROMOTION_RUN_ID", "different-run", /external expected run.*mismatch/u],
["EXPECTED_PROMOTION_RUN_ATTEMPT", "2", /external expected run.*mismatch/u],
["EXPECTED_PROMOTION_SOURCE_REVISION", "f".repeat(40), /external expected source revision.*mismatch/u],
["EXPECTED_PROMOTION_ARCHIVE_SHA256", "0".repeat(64), /external expected archive digest.*mismatch/u],
] as const) {
const result = spawnSync(process.execPath, [cliPath], {
cwd: root,
encoding: "utf8",
env: { ...baseEnvironment, [name]: value },
});
expect(result.status, name).not.toBe(0);
expect(result.stderr, name).toMatch(diagnostic);
}
} finally {
await rm(root, { recursive: true, force: true });
}
}, PROCESS_HEAVY_TIMEOUT_MS);
});
@@ -0,0 +1,708 @@
import { generateKeyPairSync } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { EventEmitter } from "node:events";
import { describe, expect, it } from "vitest";
import {
evaluatePromotionEvidence,
providerPublicKeyFingerprint,
validateProviderEvidence,
} from "../../scripts/lib/provider-evidence.ts";
import { readProviderTrust } from "../../scripts/lib/provider-trust.ts";
import { superviseProviderEvidence } from "../../scripts/lib/provider-supervisor.ts";
import { runProviderProcess } from "../../scripts/lib/provider-process-runner.ts";
import { runStageVerifiedPromotionCli } from "../../scripts/lib/stage-verified-promotion-cli.ts";
import type { ReleaseCandidateManifest } from "../../scripts/lib/release-candidate.ts";
import {
digest,
providerExpectedContext,
providerUnsigned,
signedProviderV2,
trust,
} from "./security-followup-fixture.ts";
describe("security provider evidence contracts", () => {
it("accepts signed provider v2 evidence only for the exact run, source, archive, and nonce", () => {
const now = Date.parse("2026-08-02T01:00:00.000Z");
const vulnerabilityKeys = generateKeyPairSync("ed25519");
const provenanceKeys = generateKeyPairSync("ed25519");
const expected = providerExpectedContext();
const vulnerability = signedProviderV2(
{
source: expected.source,
candidate: expected.candidate,
schemaVersion: 2,
evidenceType: "vulnerability-report",
provider: "fixture-vulnerability",
issuedAt: "2026-08-02T01:00:00.000Z",
expiresAt: "2026-08-02T02:00:00.000Z",
run: { ...expected.run, invocationNonce: "1".repeat(64) },
secretScanAttestation: expected.secretScanAttestation,
findings: [],
},
"vulnerability-key",
vulnerabilityKeys.publicKey,
vulnerabilityKeys.privateKey,
);
const provenance = signedProviderV2(
{
source: expected.source,
candidate: expected.candidate,
schemaVersion: 2,
evidenceType: "provenance-attestation",
provider: "fixture-provenance",
signer: "fixture-workload",
issuedAt: "2026-08-02T01:00:00.000Z",
expiresAt: "2026-08-02T02:00:00.000Z",
run: { ...expected.run, invocationNonce: "2".repeat(64) },
subject: { name: "dist", digest: { sha256: expected.candidate.distSha256 } },
},
"provenance-key",
provenanceKeys.publicKey,
provenanceKeys.privateKey,
);
const result = evaluatePromotionEvidence({
expected: {
...expected,
vulnerabilityInvocationNonce: "1".repeat(64),
provenanceInvocationNonce: "2".repeat(64),
},
localStatus: "PASS",
vulnerabilityReport: vulnerability,
provenanceAttestation: provenance,
vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey),
provenanceTrust: trust("provenance-key", provenanceKeys.publicKey),
nowEpochMs: () => now,
});
expect(result).toEqual({
status: "PASS",
vulnerabilityStatus: "PASS",
provenanceAttestationStatus: "PASS",
failures: [],
});
const replayed = evaluatePromotionEvidence({
expected: {
...expected,
run: { id: expected.run.id, attempt: 2 },
vulnerabilityInvocationNonce: "1".repeat(64),
provenanceInvocationNonce: "2".repeat(64),
},
localStatus: "PASS",
vulnerabilityReport: vulnerability,
provenanceAttestation: provenance,
vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey),
provenanceTrust: trust("provenance-key", provenanceKeys.publicKey),
nowEpochMs: () => now,
});
expect(replayed.status).toBe("FAIL_UNVERIFIED");
expect(replayed.failures).toEqual(
expect.arrayContaining([
"vulnerability report run identity mismatch",
"provenance attestation run identity mismatch",
]),
);
});
it("rejects a signed vulnerability PASS when the captured SARIF attestation differs", () => {
const keys = generateKeyPairSync("ed25519");
const expected = providerExpectedContext();
const secretScanAttestation = {
status: "PASS" as const,
localEvidenceAssessmentSha256: digest("assessment"),
sourceSetSha256: expected.source.sourceSetSha256,
policySha256: digest("secret policy"),
sarifSha256: digest("real sarif"),
scanInputSha256: digest("scan input"),
};
const report = signedProviderV2(
{
schemaVersion: 2,
evidenceType: "vulnerability-report",
provider: "fixture-vulnerability",
issuedAt: "2026-08-02T01:00:00.000Z",
expiresAt: "2026-08-02T02:00:00.000Z",
run: { ...expected.run, invocationNonce: "1".repeat(64) },
source: expected.source,
candidate: expected.candidate,
secretScanAttestation,
findings: [],
},
"vulnerability-key",
keys.publicKey,
keys.privateKey,
);
const validated = validateProviderEvidence({
kind: "vulnerability",
value: report,
expected: {
...expected,
vulnerabilityInvocationNonce: "1".repeat(64),
provenanceInvocationNonce: "2".repeat(64),
secretScanAttestation: {
...secretScanAttestation,
sarifSha256: digest("forged empty sarif"),
},
},
trust: trust("vulnerability-key", keys.publicKey),
nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"),
});
expect(validated.status).toBe("FAIL_UNVERIFIED");
expect(validated.failures).toContain(
"vulnerability report secret scan attestation mismatch",
);
const forged = structuredClone(report);
forged.secretScanAttestation.sarifSha256 = digest("forged empty sarif");
const forgedValidation = validateProviderEvidence({
kind: "vulnerability",
value: forged,
expected: {
...expected,
vulnerabilityInvocationNonce: "1".repeat(64),
provenanceInvocationNonce: "2".repeat(64),
secretScanAttestation: forged.secretScanAttestation,
},
trust: trust("vulnerability-key", keys.publicKey),
nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"),
});
expect(forgedValidation.failures).toContain(
"vulnerability report signature verification failed",
);
const provenanceKeys = generateKeyPairSync("ed25519");
const provenance = signedProviderV2(
{
schemaVersion: 2,
evidenceType: "provenance-attestation",
provider: "fixture-provenance",
signer: "fixture-workload",
issuedAt: "2026-08-02T01:00:00.000Z",
expiresAt: "2026-08-02T02:00:00.000Z",
run: { ...expected.run, invocationNonce: "2".repeat(64) },
source: expected.source,
candidate: expected.candidate,
subject: { name: "dist", digest: { sha256: expected.candidate.distSha256 } },
},
"provenance-key",
provenanceKeys.publicKey,
provenanceKeys.privateKey,
);
const evaluated = evaluatePromotionEvidence({
expected: {
...expected,
vulnerabilityInvocationNonce: "1".repeat(64),
provenanceInvocationNonce: "2".repeat(64),
secretScanAttestation: {
...secretScanAttestation,
sarifSha256: digest("forged empty sarif"),
},
},
localStatus: "PASS",
vulnerabilityReport: report,
provenanceAttestation: provenance,
vulnerabilityTrust: trust("vulnerability-key", keys.publicKey),
provenanceTrust: trust("provenance-key", provenanceKeys.publicKey),
nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"),
});
expect(evaluated.vulnerabilityStatus).toBe("FAIL_UNVERIFIED");
expect(evaluated.failures).toContain(
"vulnerability report secret scan attestation mismatch",
);
});
it.each(["vulnerability", "provenance"] as const)(
"rejects correctly re-signed %s v2 context/time/replay drift",
(kind) => {
const now = Date.parse("2026-08-02T01:00:00.000Z");
const vulnerabilityKeys = generateKeyPairSync("ed25519");
const provenanceKeys = generateKeyPairSync("ed25519");
const expected = providerExpectedContext();
const baseVulnerability = providerUnsigned("vulnerability", expected);
const baseProvenance = providerUnsigned("provenance", expected);
const validVulnerability = signedProviderV2(
baseVulnerability,
"vulnerability-key",
vulnerabilityKeys.publicKey,
vulnerabilityKeys.privateKey,
);
const validProvenance = signedProviderV2(
baseProvenance,
"provenance-key",
provenanceKeys.publicKey,
provenanceKeys.privateKey,
);
const rawCases: Array<readonly [
string,
(value: Record<string, any>) => Record<string, any>,
RegExp,
]> = [
["schema v1", (value) => ({ ...value, schemaVersion: 1 }), /missing or invalid/u],
[
"evidence type",
(value) => ({
...value,
evidenceType:
kind === "vulnerability"
? "provenance-attestation"
: "vulnerability-report",
}),
/missing or invalid/u,
],
...(["archiveSha256", "bundleSha256", "distSha256", "lockfileSha256"] as const).map(
(field) => [
`candidate ${field}`,
(value: Record<string, any>) => ({
...value,
candidate: { ...value.candidate, [field]: "f".repeat(64) },
...(kind === "provenance" && field === "distSha256"
? {
subject: {
name: "dist",
digest: { sha256: "f".repeat(64) },
},
}
: {}),
}),
/candidate identity|subject dist/u,
] as const,
),
[
"different archive with same dist and lockfile",
(value) => ({
...value,
candidate: { ...value.candidate, archiveSha256: "e".repeat(64) },
}),
/candidate identity/u,
],
[
"source revision",
(value) => ({ ...value, source: { ...value.source, revision: "c".repeat(40) } }),
/source identity/u,
],
[
"source set",
(value) => ({ ...value, source: { ...value.source, sourceSetSha256: "c".repeat(64) } }),
/source identity/u,
],
[
"run id",
(value) => ({ ...value, run: { ...value.run, id: "other-run" } }),
/run identity/u,
],
[
"run attempt replay",
(value) => ({ ...value, run: { ...value.run, attempt: 2 } }),
/run identity/u,
],
[
"different nonce",
(value) => ({ ...value, run: { ...value.run, invocationNonce: "3".repeat(64) } }),
/invocation nonce/u,
],
[
"missing nonce",
(value) => {
const run = { ...value.run };
delete run.invocationNonce;
return { ...value, run };
},
/missing or invalid/u,
],
[
"uppercase nonce",
(value) => ({ ...value, run: { ...value.run, invocationNonce: "A".repeat(64) } }),
/missing or invalid/u,
],
[
"short nonce",
(value) => ({ ...value, run: { ...value.run, invocationNonce: "1".repeat(62) } }),
/missing or invalid/u,
],
[
"issued future boundary",
(value) => ({ ...value, issuedAt: "2026-08-02T01:05:00.001Z" }),
/future skew/u,
],
[
"expiry equality",
(value) => ({ ...value, expiresAt: "2026-08-02T01:00:00.000Z" }),
/expired/u,
],
[
"expiry past",
(value) => ({ ...value, expiresAt: "2026-08-02T00:59:59.999Z" }),
/expired/u,
],
[
"zero lifetime",
(value) => ({
...value,
issuedAt: "2026-08-02T01:01:00.000Z",
expiresAt: "2026-08-02T01:01:00.000Z",
}),
/not positive/u,
],
[
"negative lifetime",
(value) => ({
...value,
issuedAt: "2026-08-02T01:02:00.000Z",
expiresAt: "2026-08-02T01:01:59.999Z",
}),
/not positive/u,
],
[
"lifetime above two hours",
(value) => ({
...value,
issuedAt: "2026-08-02T01:00:00.000Z",
expiresAt: "2026-08-02T03:00:00.001Z",
}),
/exceeds two hours/u,
],
[
"wrong fingerprint",
(value) => ({
...value,
signature: {
...value.signature,
publicKeyFingerprint: `sha256:${"d".repeat(64)}`,
},
}),
/trust identity/u,
],
];
const cases = rawCases.map(([name, mutate, failure]) => ({
name,
mutate,
failure,
}));
for (const testCase of cases) {
const base = kind === "vulnerability" ? baseVulnerability : baseProvenance;
const mutated = testCase.mutate(structuredClone(base));
const resigned = signedProviderV2(
mutated,
kind === "vulnerability" ? "vulnerability-key" : "provenance-key",
kind === "vulnerability" ? vulnerabilityKeys.publicKey : provenanceKeys.publicKey,
kind === "vulnerability" ? vulnerabilityKeys.privateKey : provenanceKeys.privateKey,
"signature" in mutated && mutated.signature?.publicKeyFingerprint
? mutated.signature.publicKeyFingerprint
: undefined,
);
const result = evaluatePromotionEvidence({
expected: {
...expected,
vulnerabilityInvocationNonce: "1".repeat(64),
provenanceInvocationNonce: "2".repeat(64),
},
localStatus: "PASS",
vulnerabilityReport:
kind === "vulnerability" ? resigned : validVulnerability,
provenanceAttestation:
kind === "provenance" ? resigned : validProvenance,
vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey),
provenanceTrust: trust("provenance-key", provenanceKeys.publicKey),
nowEpochMs: () => now,
});
expect(result.status, testCase.name).toBe("FAIL_UNVERIFIED");
expect(result.failures.join("\n"), testCase.name).toMatch(testCase.failure);
}
},
);
it("canonicalizes provider fingerprints from DER SPKI across PEM wrapping and rejects Ed448", async () => {
const root = await mkdtemp(path.join(tmpdir(), "provider-fingerprint-"));
try {
const ed25519 = generateKeyPairSync("ed25519").publicKey;
const pem = ed25519.export({ type: "spki", format: "pem" }).toString();
const body = pem.replace(/-----[^-]+-----|\s/gu, "");
const wrapped = (width: number) =>
`-----BEGIN PUBLIC KEY-----\n${body.match(new RegExp(`.{1,${width}}`, "gu"))!.join("\n")}\n-----END PUBLIC KEY-----\n`;
await writeFile(path.join(root, "a.pem"), wrapped(64));
await writeFile(path.join(root, "b.pem"), wrapped(32));
const first = await readProviderTrust(root, "a.pem", "fixture-key");
const second = await readProviderTrust(root, "b.pem", "fixture-key");
expect(first?.publicKeyFingerprint).toBe(providerPublicKeyFingerprint(ed25519));
expect(second?.publicKeyFingerprint).toBe(first?.publicKeyFingerprint);
const ed448 = generateKeyPairSync("ed448").publicKey;
await writeFile(root + "/ed448.pem", ed448.export({ type: "spki", format: "pem" }));
await expect(readProviderTrust(root, "ed448.pem", "fixture-key")).resolves.toBeNull();
expect(() => providerPublicKeyFingerprint(ed448)).toThrow(/must be Ed25519/u);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("captures the downloaded archive pathname exactly once in the provider supervisor", async () => {
const keys = generateKeyPairSync("ed25519");
const expected = providerExpectedContext();
let captureCount = 0;
let receivedEnvironment: Readonly<Record<string, string>> | undefined;
const manifest: ReleaseCandidateManifest = {
schemaVersion: 1,
distSha256: expected.candidate.distSha256,
lockfileSha256: expected.candidate.lockfileSha256,
bundleSha256: expected.candidate.bundleSha256,
files: [{ path: "pnpm-lock.yaml", bytes: 1, sha256: expected.candidate.lockfileSha256 }],
};
const result = await superviseProviderEvidence(
{
kind: "vulnerability",
archivePath: "/downloads/candidate.tar.gz",
expectedArchiveSha256: expected.candidate.archiveSha256,
expectedRun: {
id: expected.run.id,
attempt: expected.run.attempt,
sourceRevision: expected.source.revision,
},
trust: trust("vulnerability-key", keys.publicKey),
executeProvider: async ({ environment }) => {
receivedEnvironment = environment;
},
captureReport: async () => Buffer.from("{}\n"),
},
{
captureArchive: async (input) => {
captureCount += 1;
expect(input).toEqual({
archivePath: "/downloads/candidate.tar.gz",
expectedSha256: expected.candidate.archiveSha256,
});
return {
bytes: Buffer.from("captured archive"),
archiveSha256: expected.candidate.archiveSha256,
};
},
withVerifiedCandidate: (async (input: any) =>
input.verify({ extractionRoot: "/captured/extraction", manifest })) as any,
verifyLocalEvidence: async () => ({
status: "PASS",
identity: {
sourceRevision: expected.source.revision,
sourceSetSha256: expected.source.sourceSetSha256,
assessmentSha256: digest("assessment"),
secretScan: {
policySha256: digest("provider secret policy"),
sarifSha256: digest("provider secret sarif"),
scanInputSha256: digest("provider secret input"),
},
},
failures: [],
}),
validateUpload: (async (input: any) => {
expect("archivePath" in input).toBe(false);
return { sealed: true };
}) as any,
randomBytes: () => Buffer.alloc(32, 0x11),
nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"),
},
);
expect(captureCount).toBe(1);
expect(receivedEnvironment).toEqual(
expect.objectContaining({
PROVIDER_EVIDENCE_SCHEMA_VERSION: "2",
PROVIDER_INVOCATION_NONCE: "11".repeat(32),
PROVIDER_ISSUED_AT: "2026-08-02T01:00:00.000Z",
PROVIDER_EXPIRES_AT: "2026-08-02T02:00:00.000Z",
CI_RUN_ID: expected.run.id,
CI_RUN_ATTEMPT: "1",
SOURCE_REVISION: expected.source.revision,
CANDIDATE_ARCHIVE_SHA256: expected.candidate.archiveSha256,
}),
);
expect(result.evidence).toEqual({ sealed: true });
});
it("samples provider freshness after report capture instead of reusing issuance time", async () => {
const keys = generateKeyPairSync("ed25519");
const expected = providerExpectedContext();
const manifest: ReleaseCandidateManifest = {
schemaVersion: 1,
distSha256: expected.candidate.distSha256,
lockfileSha256: expected.candidate.lockfileSha256,
bundleSha256: expected.candidate.bundleSha256,
files: [{ path: "pnpm-lock.yaml", bytes: 1, sha256: expected.candidate.lockfileSha256 }],
};
const issuedSample = Date.parse("2026-08-02T01:00:00.000Z");
const validationSample = Date.parse("2026-08-02T02:00:00.001Z");
const samples = [issuedSample, validationSample];
let issuedAt = "";
await expect(
superviseProviderEvidence(
{
kind: "vulnerability",
archivePath: "/downloads/candidate.tar.gz",
expectedArchiveSha256: expected.candidate.archiveSha256,
expectedRun: {
id: expected.run.id,
attempt: expected.run.attempt,
sourceRevision: expected.source.revision,
},
trust: trust("vulnerability-key", keys.publicKey),
executeProvider: async ({ environment }) => {
issuedAt = environment.PROVIDER_ISSUED_AT!;
},
captureReport: async () => Buffer.from("{}\n"),
},
{
captureArchive: async () => ({
bytes: Buffer.from("captured archive"),
archiveSha256: expected.candidate.archiveSha256,
}),
withVerifiedCandidate: (async (input: any) =>
input.verify({ extractionRoot: "/captured/extraction", manifest })) as any,
verifyLocalEvidence: async () => ({
status: "PASS",
identity: {
sourceRevision: expected.source.revision,
sourceSetSha256: expected.source.sourceSetSha256,
assessmentSha256: digest("assessment"),
secretScan: {
policySha256: digest("provider secret policy"),
sarifSha256: digest("provider secret sarif"),
scanInputSha256: digest("provider secret input"),
},
},
failures: [],
}),
validateUpload: (async (input: any) => {
expect(input.nowEpochMs()).toBe(validationSample);
throw new Error("provider report expired during execution");
}) as any,
randomBytes: () => Buffer.alloc(32, 0x33),
nowEpochMs: () => samples.shift()!,
},
),
).rejects.toThrow(/expired during execution/u);
expect(issuedAt).toBe("2026-08-02T01:00:00.000Z");
});
it("kills a timed-out provider but settles only after the child closes", async () => {
const child = new EventEmitter() as EventEmitter & {
kill(signal: NodeJS.Signals): boolean;
};
let killedWith: NodeJS.Signals | undefined;
child.kill = (signal) => {
killedWith = signal;
return true;
};
let fireTimeout: (() => void) | undefined;
let settled = false;
const running = runProviderProcess(
{ executable: "/usr/bin/bwrap", arguments: [], environment: {}, timeoutMs: 1 },
{
spawnChild: () => child as any,
setTimer: (callback) => {
fireTimeout = callback;
return 1 as any;
},
clearTimer: () => undefined,
},
).finally(() => {
settled = true;
});
fireTimeout?.();
await Promise.resolve();
expect(killedWith).toBe("SIGKILL");
expect(settled).toBe(false);
child.emit("close", null, "SIGKILL");
await expect(running).rejects.toThrow(/timed out/u);
expect(settled).toBe(true);
});
it("captures process-group kill errors, attempts child fallback, and settles after close", async () => {
const child = new EventEmitter() as EventEmitter & {
pid: number;
kill(signal: NodeJS.Signals): boolean;
};
child.pid = 12_346;
let fallbackSignal: NodeJS.Signals | undefined;
child.kill = (signal) => {
fallbackSignal = signal;
return true;
};
let fireTimeout: (() => void) | undefined;
const running = runProviderProcess(
{ executable: "/usr/bin/bwrap", arguments: [], environment: {}, timeoutMs: 1 },
{
spawnChild: () => child as any,
setTimer: (callback) => {
fireTimeout = callback;
return 1 as any;
},
clearTimer: () => undefined,
killProcessGroup: () => {
throw Object.assign(new Error("group kill denied"), { code: "EPERM" });
},
},
);
expect(() => fireTimeout?.()).not.toThrow();
expect(fallbackSignal).toBe("SIGKILL");
child.emit("close", null, "SIGKILL");
await expect(running).rejects.toThrow(/timed out.*kill failed.*close/u);
});
it.each(["open failure", "partial write failure"])(
"cleans finalized staging from memory when GITHUB_OUTPUT has a %s",
async (failureKind) => {
const finalized = {
stagingRoot: "/runner/promotion-run-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
cleanupToken: "promotion-run-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
runnerTempIdentity: { dev: 10, ino: 20 },
stagingIdentity: { dev: 30, ino: 40 },
files: [],
} as const;
let cleanupInput: unknown;
let appendCalls = 0;
const environment = {
CANDIDATE_ARCHIVE_PATH: "candidate.tar.gz",
CANDIDATE_ARCHIVE_SHA256: "a".repeat(64),
VULNERABILITY_REPORT_PATH: "vulnerability.json",
PROVENANCE_ATTESTATION_PATH: "provenance.json",
VULNERABILITY_PUBLIC_KEY_PATH: "vulnerability.pem",
VULNERABILITY_KEY_ID: "vulnerability-key",
PROVENANCE_PUBLIC_KEY_PATH: "provenance.pem",
PROVENANCE_KEY_ID: "provenance-key",
CI_RUN_ID: "run",
CI_RUN_ATTEMPT: "1",
VITE_COMMIT_SHA: "b".repeat(40),
VULNERABILITY_INVOCATION_NONCE: "c".repeat(64),
PROVENANCE_INVOCATION_NONCE: "d".repeat(64),
RUNNER_TEMP: "/runner",
GITHUB_OUTPUT: "/runner/github-output",
};
await expect(
runStageVerifiedPromotionCli(environment, {
cwd: () => "/workspace",
finalize: async () => finalized as any,
appendOutput: async () => {
appendCalls += 1;
if (failureKind === "partial write failure") {
// The output sink accepted an unspecified prefix before rejecting.
}
throw new Error(failureKind);
},
cleanup: async (input) => {
cleanupInput = input;
},
writeStdout: () => undefined,
}),
).rejects.toThrow(new RegExp(failureKind, "u"));
expect(appendCalls).toBe(1);
expect(cleanupInput).toEqual({
runnerTempRoot: "/runner",
stagingRoot: finalized.stagingRoot,
cleanupToken: finalized.cleanupToken,
runnerTempIdentity: finalized.runnerTempIdentity,
stagingIdentity: finalized.stagingIdentity,
});
},
);
});
@@ -182,8 +182,8 @@ describe("selective Task 3 contract closure", () => {
it.skipIf(isReducedCiContractRun())("accepts only the canonical exact-count authority and rejects orphan retention", async () => { it.skipIf(isReducedCiContractRun())("accepts only the canonical exact-count authority and rejects orphan retention", async () => {
const canonical = await loadCiGateContract(process.cwd()); const canonical = await loadCiGateContract(process.cwd());
expect(canonical.gates).toHaveLength(27); expect(canonical.gates).toHaveLength(27);
expect(canonical.commands).toHaveLength(84); expect(canonical.commands).toHaveLength(91);
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(96); expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(103);
expect(canonical.artifacts).toHaveLength(109); expect(canonical.artifacts).toHaveLength(109);
expect(canonical.stages).toHaveLength(5); expect(canonical.stages).toHaveLength(5);
expect(canonical.retention.classes).toHaveLength(5); expect(canonical.retention.classes).toHaveLength(5);