refactor: 프론트엔드 리펙토링
This commit is contained in:
@@ -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"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">5x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </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";
|
||||
|
||||
export type ReferenceFeatureRouteId =
|
||||
keyof typeof REFERENCE_FEATURE_CONTRACT.routes;
|
||||
|
||||
const defineReferenceHttpOperation =
|
||||
defineFeatureHttpOperationForRoutes<ReferenceFeatureRouteId>();
|
||||
|
||||
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);
|
||||
|
||||
export type ReferenceHttpBinding = FeatureHttpBinding<
|
||||
typeof REFERENCE_HTTP_OPERATIONS
|
||||
>;
|
||||
|
||||
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,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
</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
@@ -81,6 +81,55 @@
|
||||
"script": "check:types:fixture:reference-operation",
|
||||
"expect": "fail",
|
||||
"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:"
|
||||
},
|
||||
{
|
||||
@@ -95,7 +144,7 @@
|
||||
"script": "check:types:fixture:route-runtime",
|
||||
"expect": "fail",
|
||||
"expectedExitCode": 1,
|
||||
"expectedDiagnosticId": "error TS2353:"
|
||||
"expectedDiagnosticId": "error TS2740:"
|
||||
},
|
||||
{
|
||||
"id": "check-types-fixture-page-action",
|
||||
@@ -1698,6 +1747,13 @@
|
||||
"check-types-fixture-feature-input",
|
||||
"check-types-fixture-failure-kind",
|
||||
"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-route-runtime",
|
||||
"check-types-fixture-page-action",
|
||||
|
||||
@@ -12,97 +12,192 @@
|
||||
{
|
||||
"path": "src/adapters/http/bounded-body-reader.ts",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"owner": "application-policy",
|
||||
"minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 75 }
|
||||
"path": "src/contracts/compatibility.ts",
|
||||
"owner": "compatibility-contracts",
|
||||
"minimum": {
|
||||
"lines": 95,
|
||||
"statements": 95,
|
||||
"functions": 95,
|
||||
"branches": 75
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/application/policies/performance-budgets.ts",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"owner": "presentation-runtime",
|
||||
"minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 80 }
|
||||
"minimum": {
|
||||
"lines": 90,
|
||||
"statements": 90,
|
||||
"functions": 90,
|
||||
"branches": 80
|
||||
}
|
||||
}
|
||||
],
|
||||
"highRiskPaths": [
|
||||
@@ -116,7 +211,7 @@
|
||||
"src/adapters/storage/browser-storage-adapter.ts",
|
||||
"src/adapters/telemetry/best-effort-telemetry.ts",
|
||||
"src/application/create-application.ts",
|
||||
"src/application/policies/compatibility.ts",
|
||||
"src/contracts/compatibility.ts",
|
||||
"src/application/policies/performance-budgets.ts",
|
||||
"src/application/policies/promotion-readiness.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
|
||||
|
||||
- 접근 가능한 `<input type="file">`가 모든 browser의 canonical baseline이다.
|
||||
baseline adapter는 `input.click()`으로 이 경로를 활성화한다. `showPicker()`
|
||||
존재 여부는 portable baseline capability의 판정 기준으로 사용하지 않는다.
|
||||
- `showOpenFilePicker()`와 `showSaveFilePicker()`는 runtime method별 feature
|
||||
detection을 거친 progressive enhancement다. UA sniffing을 사용하지 않는다.
|
||||
- picker 호출은 click/keyboard handler의 첫 browser action이어야 한다. 그 전에
|
||||
@@ -987,7 +989,7 @@ recipe의 공통 code는 UI/telemetry에 raw DOMException을 노출하지 않기
|
||||
| `PERMISSION_DENIED` | picker/save permission | baseline/manual fallback |
|
||||
| `LIMIT_EXCEEDED` | count/bytes/page/buffer budget | 입력 축소 |
|
||||
| `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 |
|
||||
| `MIGRATION_FAILED` | schema/data migration | read-only/online-only |
|
||||
| `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 |
|
||||
| `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은
|
||||
adapter에서 이 vocabulary로 mapping하고 raw message/stack은 local bounded
|
||||
diagnostic에도 기본 저장하지 않는다.
|
||||
@@ -1085,18 +1093,17 @@ Playwright Chromium, Firefox, WebKit에서 실제 secure-origin API를 검사한
|
||||
`test:browser-capabilities`의 JUnit을
|
||||
`verify:browser-capability-evidence`가 읽어 세 engine의 testcase 집합 동일성,
|
||||
양수 실행 수, zero failure/error/skipped와 skipped/failure node 부재를 강제한다.
|
||||
현재 checkout의 source suite는 engine마다 정확히 같은 14개 case(File 2,
|
||||
IndexedDB 4, OPFS/Cache/StorageManager 각 1, cross-context invalidation 2,
|
||||
presigned streaming download/multipart upload/Image CDN 각 1)를 정의한다.
|
||||
promotion artifact는 Chromium/Firefox/WebKit의 14개씩, 총 42개가 모두
|
||||
실행되어야 한다. 이 host의
|
||||
WebKit은 필수 native libraries(예:
|
||||
`libbacktrace.so.0`, `libevent-2.1.so.7`, `libjxl.so.0.8`,
|
||||
`libavif.so.16`과 WPE 계열) 부재로 실행되지 않았고 현재 보존 artifact도
|
||||
Chromium/Firefox 14개씩 총 28개만 통과한 상태다. 따라서 promotion evidence를
|
||||
충족하지 않으며 verifier가 실패하는 것이 정상이다. `INSTALLED` 전에는 필요한
|
||||
system dependency가 있는 CI/device에서 세 engine 전체 evidence를 새로 생성해야
|
||||
한다.
|
||||
browser capability promotion은 source suite에서 관찰된 동일 testcase set을
|
||||
Chromium/Firefox/WebKit에서 모두 실행하는 규칙으로 관리한다. case 수를 문서
|
||||
상수로 복제하지 않고 `verify:browser-capability-evidence`가 Chromium 결과를
|
||||
baseline set으로 계산해 Firefox/WebKit의 set 동일성, 양수 실행 수,
|
||||
failure/error/skipped 0과 failure/skipped node 부재를 검증한다.
|
||||
|
||||
capability absence도 skip 사유가 아니다. 예를 들어 어떤 engine에서
|
||||
`navigator.storage`가 없다면 동일 testcase 안에서 adapter의
|
||||
`UNSUPPORTED / STORAGE_ESTIMATE / ONLINE_ONLY` 결과를 browser truth로
|
||||
검증한다. 세 engine을 실행할 수 없는 host의 artifact는 promotion evidence로
|
||||
사용하지 않는다.
|
||||
|
||||
- native input keyboard/focus/same-file reselection/multiple/dismissal
|
||||
- Chromium conditional picker/save enhancement와 다른 engine fallback
|
||||
|
||||
@@ -48,18 +48,39 @@ its business requirement.
|
||||
|
||||
## 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`
|
||||
|
||||
with the feature-owned fixture:
|
||||
with its isolated fixture:
|
||||
|
||||
`tests/contract/consumer-experience/fixtures/local-draft-feature.ts`
|
||||
|
||||
The fixture models a small local-draft feature with save/find/remove and
|
||||
optimistic revision checking.
|
||||
The architecture is now also exercised by a real vertical slice under:
|
||||
|
||||
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 |
|
||||
| --- | ---: |
|
||||
@@ -81,8 +102,11 @@ The contract test rejects feature source that reaches for
|
||||
|
||||
## What this does and does not prove
|
||||
|
||||
The probe confirms that **feature business code does not need native IndexedDB
|
||||
knowledge** once an `IndexedDbRepositoryPort` has been composed.
|
||||
The contract probe confirms that **feature business code does not need native
|
||||
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.
|
||||
That constructor still owns substantial infrastructure policy:
|
||||
@@ -96,10 +120,15 @@ That constructor still owns substantial infrastructure policy:
|
||||
- durability, scheduling and observation.
|
||||
|
||||
That complexity belongs at the composition/platform boundary, not in the
|
||||
feature. A new `create...Repository<T>` convenience factory should be added
|
||||
only when a second real product consumer demonstrates which subset is stable
|
||||
enough to become a reusable profile. Creating one now would guess at policy and
|
||||
risk producing a universal storage abstraction.
|
||||
feature. The Local Draft vertical slice is now the second concrete consumer, and
|
||||
it confirms that the stable seam is the typed repository provider plus a
|
||||
feature-owned repository identity. It does **not** show that dataset scope,
|
||||
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
|
||||
|
||||
|
||||
@@ -189,17 +189,18 @@ chunk별 `CapabilityResult<Uint8Array>`를 반환한다. backend upload example
|
||||
실제 upload feature는 이 예시를 그대로 import하지 않고 purpose와 backend
|
||||
protocol에 맞게 contract를 더 좁힌다.
|
||||
|
||||
현재 checkout의 browser source suite는 engine마다 같은 14개 case(File 2,
|
||||
IndexedDB 4, OPFS/Cache/StorageManager 각 1, cross-context invalidation 2,
|
||||
presigned streaming download/multipart upload/Image CDN 각 1)를 정의한다.
|
||||
promotion artifact는 Chromium/Firefox/WebKit 각각 14개, 총 42개를 모두
|
||||
실행해야 한다. WebKit은 현재
|
||||
host의 필수 native libraries(예:
|
||||
`libbacktrace.so.0`, `libevent-2.1.so.7`, `libjxl.so.0.8`,
|
||||
`libavif.so.16`과 WPE 계열) 부재로 실행되지 않았다. 보존 artifact는
|
||||
Chromium/Firefox 14개씩 총 28개만 통과했으므로
|
||||
`verify:browser-capability-evidence`가 실패하는 것이 정상이다. 세 engine
|
||||
evidence가 완성되기 전에는 product 상태를 `INSTALLED`로 올리지 않는다.
|
||||
browser capability promotion은 source suite가 정의한 동일 testcase set을
|
||||
Chromium/Firefox/WebKit에서 모두 실행해야 한다. 구체적인 case 개수는 이 결정
|
||||
문서에 고정하지 않는다. `verify:browser-capability-evidence`가 Chromium
|
||||
artifact에서 baseline set을 계산하고 Firefox/WebKit과의 set 동일성 및
|
||||
failure/error/skipped 0을 기계적으로 검증한다.
|
||||
|
||||
engine별 native capability 차이는 testcase를 skip하는 이유가 아니다. capability가
|
||||
없으면 adapter의 명시적 unsupported/degraded result를 같은 testcase에서
|
||||
검증한다. baseline `<input type="file">` activation은 portable한
|
||||
`input.click()` 경로를 사용하고, `showOpenFilePicker()` 같은 API는 별도
|
||||
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
|
||||
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
|
||||
|
||||
`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-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
|
||||
|
||||
@@ -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-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/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`,
|
||||
`tests/unit/supply-chain.test.ts` and
|
||||
|
||||
@@ -148,6 +148,9 @@ read-only나 online-only가 사용자 작성 내용을 잃게 한다면 먼저 e
|
||||
### 신호
|
||||
|
||||
- `BLOCKED`, `UPGRADE_BLOCKED` 또는 blocked duration bucket 증가
|
||||
- live attempt가 기다리는 동안 status는 `BLOCKED`
|
||||
- blocked deadline settle 뒤에는 open result가 `BLOCKED` failure여도
|
||||
runtime status는 `CLOSED / NOT_OPENED`
|
||||
- `versionchange` 뒤 connection이 남음
|
||||
- repeated reload/update loop
|
||||
- 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도 함께 보존한다.
|
||||
|
||||
- `test:browser-capabilities`가 만든 JUnit에서 Chromium, Firefox, WebKit이 동일
|
||||
14개 testcase set(File 2, IndexedDB 4, OPFS/Cache/StorageManager 각 1,
|
||||
cross-context invalidation 2, presigned streaming download/multipart
|
||||
upload/Image CDN 각 1)을 실제 실행해 총 42개이며 failure/error/skipped가 모두
|
||||
0이어야 한다.
|
||||
`verify:browser-capability-evidence`가 engine 집합과 testcase 동일성을
|
||||
기계적으로 검증한다. 현재 artifact는 Chromium/Firefox 14개씩 총 28개가
|
||||
통과했지만 WebKit 실행에 필요한 native libraries(예:
|
||||
`libbacktrace.so.0`, `libevent-2.1.so.7`, `libjxl.so.0.8`,
|
||||
`libavif.so.16`과 WPE 계열)가 이 host에 없으므로 아직 promotion 가능 상태가
|
||||
- `test:browser-capabilities`가 만든 JUnit에서 Chromium, Firefox, WebKit이
|
||||
source suite의 동일 testcase set을 실제 실행하고 failure/error/skipped가 모두
|
||||
0이어야 한다. 구체적인 case 수는 이 runbook에 복제하지 않는다.
|
||||
`verify:browser-capability-evidence`가 Chromium을 baseline으로 testcase set과
|
||||
engine 집합을 동적으로 검증한다. native capability가 없는 engine도 testcase를
|
||||
skip하지 않고 adapter의 명시적 `UNSUPPORTED`/degraded 결과를 검증해야 한다.
|
||||
세 engine 중 하나라도 실행되지 않은 artifact는 promotion 가능 상태가
|
||||
아니다.
|
||||
- `artifacts/quality/vite-module-inventory.json`에서 optional runtime source root가
|
||||
production chunk에 없음을 `check:optional-recipes`로 검증한다.
|
||||
|
||||
@@ -105,6 +105,24 @@ component suite로 분리되어 있다.
|
||||
실제 QueryClient 위에서 검증한다. HTTP 자동 retry가 소유자이므로 이 adapter의
|
||||
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 component/reference feature test가 error summary, 첫 오류 focus, Zod
|
||||
|
||||
@@ -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` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
| end-to-end | `pnpm test:e2e` | pinned browser engines | `artifacts/tests/e2e/` |
|
||||
| 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
|
||||
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,
|
||||
Firefox, and WebKit engines. The responsive contract explicitly exercises
|
||||
|
||||
+24
-17
@@ -30,22 +30,22 @@
|
||||
"check:types:node": "tsc --project tsconfig.node.json",
|
||||
"check:types:test": "tsc --project tsconfig.test.json",
|
||||
"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:ts-port": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-port-implementation.ts",
|
||||
"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:application-output": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-output.ts",
|
||||
"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:feature-input": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-feature-input.ts",
|
||||
"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:reference-operation": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-reference-operation.ts",
|
||||
"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:route-runtime": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-route-runtime.ts",
|
||||
"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: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:i18n-key": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-key.ts",
|
||||
"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:diagnostics": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-diagnostics-port.ts",
|
||||
"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": "tsc -p tests/fixtures/typecheck/tsconfig.port-call.json",
|
||||
"check:types:fixture:ts-port": "tsc -p tests/fixtures/typecheck/tsconfig.port-implementation.json",
|
||||
"check:types:fixture:ts-result": "tsc -p tests/fixtures/typecheck/tsconfig.result-narrowing.json",
|
||||
"check:types:fixture:application-output": "tsc -p tests/fixtures/typecheck/tsconfig.application-output.json",
|
||||
"check:types:fixture:application-input": "tsc -p tests/fixtures/typecheck/tsconfig.application-input.json",
|
||||
"check:types:fixture:feature-input": "tsc -p tests/fixtures/typecheck/tsconfig.feature-input.json",
|
||||
"check:types:fixture:failure-kind": "tsc -p tests/fixtures/typecheck/tsconfig.failure-kind.json",
|
||||
"check:types:fixture:reference-operation": "tsc -p tests/fixtures/typecheck/tsconfig.reference-operation.json",
|
||||
"check:types:fixture:async-overlay": "tsc -p tests/fixtures/typecheck/tsconfig.async-overlay.json",
|
||||
"check:types:fixture:route-runtime": "tsc -p tests/fixtures/typecheck/tsconfig.route-runtime.json",
|
||||
"check:types:fixture:page-action": "tsc -p tests/fixtures/typecheck/tsconfig.page-action.json",
|
||||
"check:types:fixture:icon-button": "tsc -p tests/fixtures/typecheck/tsconfig.icon-button.json",
|
||||
"check:types:fixture:i18n-key": "tsc -p tests/fixtures/typecheck/tsconfig.message-key.json",
|
||||
"check:types:fixture:i18n-params": "tsc -p tests/fixtures/typecheck/tsconfig.message-params.json",
|
||||
"check:types:fixture:diagnostics": "tsc -p tests/fixtures/typecheck/tsconfig.diagnostics-port.json",
|
||||
"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: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",
|
||||
@@ -124,7 +124,14 @@
|
||||
"build:app-only": "vite build && node scripts/generate-build-manifest.ts",
|
||||
"generate:contract-set": "node scripts/generate-contract-set.ts",
|
||||
"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": {
|
||||
"@tanstack/react-query": "5.101.4",
|
||||
|
||||
@@ -443,7 +443,7 @@ export type LoadCiGateContractOptions = Readonly<{
|
||||
}>;
|
||||
|
||||
const CANONICAL_GATE_SHAPE_SHA256 =
|
||||
"372959f50b9c5a228bdc85dce33ca7968c72414ce3b6d5534c9e6eaf554f6ce4";
|
||||
"43dc4ed1cdaf21a674bd069d42eb2c32276afd8f763d72e52c9f7c3adccbeb58";
|
||||
|
||||
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
|
||||
const normalized = gates.map(
|
||||
@@ -477,9 +477,9 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
|
||||
if (contract.gates.length !== 27) {
|
||||
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(
|
||||
`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) {
|
||||
|
||||
+109
-12
@@ -42,18 +42,39 @@ const featureOwnedPaths = [
|
||||
featureSource,
|
||||
featureTests,
|
||||
"tests/integration/http-scenario-catalog.test.ts",
|
||||
"tests/component/product-feature-switch.test.tsx",
|
||||
"tests/e2e/reference-form.spec.ts",
|
||||
"tests/e2e/reference-route.spec.ts",
|
||||
"tests/mocks",
|
||||
"tests/fixtures/typecheck/invalid-feature-input.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";
|
||||
import { PLATFORM_ROUTE_REGISTRY, type RouteDefinition } from "../contracts/routes.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_RUNTIME_CONTRACT = PLATFORM_ROUTE_RUNTIME_CONTRACT;
|
||||
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 SCHEMA_REGISTRY = PLATFORM_SCHEMA_REGISTRY;
|
||||
export const ROUTE_FEATURE_OWNER: Readonly<Record<string, string>> = Object.freeze({});
|
||||
export const NAVIGATION_ROUTES = Object.freeze(
|
||||
Object.values(ROUTE_REGISTRY)
|
||||
.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";
|
||||
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_RUNTIME = PLATFORM_ROUTE_RUNTIME;
|
||||
`;
|
||||
|
||||
const emptyAdapters = `export function createInstalledFeatureInputs(_context: unknown) {
|
||||
void _context;
|
||||
return Object.freeze({});
|
||||
const emptyAdapters = `import {
|
||||
composeFeatureAdapterInputs,
|
||||
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 InstalledContractPackageIdentity,
|
||||
} 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[] =
|
||||
Object.freeze([]);
|
||||
Object.freeze(
|
||||
COMPILED_CONTRACT_CONTRIBUTIONS.filter((contribution) =>
|
||||
INSTALLED_PRODUCT_FEATURE_IDS.includes(contribution.featureId),
|
||||
),
|
||||
);
|
||||
|
||||
export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions(
|
||||
INSTALLED_CONTRACT_CONTRIBUTIONS,
|
||||
@@ -181,6 +236,10 @@ try {
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
await writeFile(
|
||||
path.join(fixtureRoot, "src/features/installed-product-manifest.ts"),
|
||||
emptyProductManifest,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(fixtureRoot, "src/features/installed-feature-contracts.ts"),
|
||||
emptyContracts,
|
||||
@@ -202,6 +261,34 @@ try {
|
||||
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(
|
||||
(modulePolicy) => !modulePolicy.path?.startsWith(`${featureSource}/`),
|
||||
);
|
||||
@@ -296,6 +383,12 @@ try {
|
||||
const removedCiScripts = new Set([
|
||||
"check:types:fixture:feature-input",
|
||||
"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:reference-feature",
|
||||
]);
|
||||
@@ -370,13 +463,17 @@ try {
|
||||
["build", runPnpm("build")],
|
||||
];
|
||||
const builtResidue: string[] = [];
|
||||
for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) {
|
||||
if (!/\.(?:js|css|html|json)$/.test(file)) continue;
|
||||
const content = await readFile(file, "utf8");
|
||||
if (
|
||||
/REFERENCE_RESOURCE|reference-feature|reference-resource/i.test(content)
|
||||
) {
|
||||
builtResidue.push(path.relative(fixtureRoot, file));
|
||||
const buildSucceeded =
|
||||
checks.find(([name]) => name === "build")?.[1] === true;
|
||||
if (buildSucceeded) {
|
||||
for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) {
|
||||
if (!/\.(?:js|css|html|json)$/.test(file)) continue;
|
||||
const content = await readFile(file, "utf8");
|
||||
if (
|
||||
/REFERENCE_RESOURCE|reference-feature|reference-resource/i.test(content)
|
||||
) {
|
||||
builtResidue.push(path.relative(fixtureRoot, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
const routeCatalog = await import(
|
||||
|
||||
@@ -108,14 +108,12 @@ export class NativeInputFilePicker implements DisposableFilePicker {
|
||||
const addInputEvent = options.input.addEventListener;
|
||||
const removeInputEvent = options.input.removeEventListener;
|
||||
const getInputAttribute = options.input.getAttribute;
|
||||
const showPicker = options.input.showPicker;
|
||||
const click = options.input.click;
|
||||
if (
|
||||
typeof addInputEvent !== "function" ||
|
||||
typeof removeInputEvent !== "function" ||
|
||||
typeof getInputAttribute !== "function" ||
|
||||
(typeof showPicker !== "function" &&
|
||||
typeof click !== "function")
|
||||
typeof click !== "function"
|
||||
) {
|
||||
throw new TypeError("Native file input API is invalid.");
|
||||
}
|
||||
@@ -123,10 +121,11 @@ export class NativeInputFilePicker implements DisposableFilePicker {
|
||||
add: addInputEvent.bind(options.input),
|
||||
remove: removeInputEvent.bind(options.input),
|
||||
getAttribute: getInputAttribute.bind(options.input),
|
||||
activate:
|
||||
typeof showPicker === "function"
|
||||
? showPicker.bind(options.input)
|
||||
: click.bind(options.input),
|
||||
// The baseline <input type="file"> path intentionally uses click().
|
||||
// showPicker() exists in multiple engines but is not a portable
|
||||
// automation/event-interception contract. Enhanced native picking is a
|
||||
// separate capability owned by EnhancedFilePicker.
|
||||
activate: click.bind(options.input),
|
||||
});
|
||||
const windowHost = options.window ?? globalThis.window;
|
||||
if (windowHost) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Result } from "../../contracts/result.ts";
|
||||
import type {
|
||||
MappingResult,
|
||||
} from "../../contracts/boundary-mapper.ts";
|
||||
InstalledHttpContract,
|
||||
} from "../../contracts/external-contract-runtime.ts";
|
||||
import type { Result } from "../../contracts/result.ts";
|
||||
import type { MappingResult } from "../../contracts/boundary-mapper.ts";
|
||||
import {
|
||||
createFailure,
|
||||
kindForStatus,
|
||||
@@ -10,55 +11,199 @@ import {
|
||||
type FailureKind,
|
||||
} from "../../contracts/errors.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<{
|
||||
execute(
|
||||
operationId: string,
|
||||
input: unknown,
|
||||
execute<Input, WireOutput, Problem>(
|
||||
contract: InstalledHttpContract<Input, WireOutput, Problem>,
|
||||
input: Input,
|
||||
context: Readonly<{
|
||||
routeId: string;
|
||||
signal?: AbortSignal;
|
||||
intent?: MutationIntent;
|
||||
}>,
|
||||
): Promise<HttpExecutionOutcome<unknown, unknown>>;
|
||||
): Promise<HttpExecutionOutcome<WireOutput, Problem>>;
|
||||
}>;
|
||||
|
||||
type FeatureHttpOperationSpec<Value> = Readonly<{
|
||||
operationId: string;
|
||||
routeId: string;
|
||||
mapSuccess(value: unknown): MappingResult<Value>;
|
||||
export type FeatureHttpProblemMapping = Readonly<{
|
||||
kind: FailureKind;
|
||||
code: string;
|
||||
}>;
|
||||
|
||||
export type FeatureHttpOperation<Input, Value> =
|
||||
FeatureHttpOperationSpec<Value> &
|
||||
Readonly<{
|
||||
/**
|
||||
* Compile-time only carrier. Feature bindings remain plain frozen objects
|
||||
* at runtime while preserving each operation's input/value pair.
|
||||
*/
|
||||
__types?: Readonly<{
|
||||
input: Input;
|
||||
value: Value;
|
||||
}>;
|
||||
type FeatureHttpOperationSpec<
|
||||
Contract,
|
||||
Value,
|
||||
RouteId extends string,
|
||||
> = Readonly<{
|
||||
contract: Contract;
|
||||
routeId: RouteId;
|
||||
mapSuccess(
|
||||
value: HttpContractWireOutput<Contract>,
|
||||
): 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>(
|
||||
spec: FeatureHttpOperationSpec<Value>,
|
||||
): FeatureHttpOperation<Input, Value> {
|
||||
return Object.freeze(spec) as FeatureHttpOperation<Input, Value>;
|
||||
type ContractValidity<Contract> =
|
||||
HttpContractOperationId<Contract> extends never
|
||||
? never
|
||||
: 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> =
|
||||
Operation extends FeatureHttpOperation<infer Input, unknown> ? Input : never;
|
||||
export function defineFeatureHttpOperation<
|
||||
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> =
|
||||
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<
|
||||
Operations extends Readonly<
|
||||
Record<string, FeatureHttpOperation<unknown, unknown>>
|
||||
>,
|
||||
Operations extends Readonly<Record<string, FeatureHttpOperationShape>>,
|
||||
> = Readonly<{
|
||||
execute<OperationId extends keyof Operations & string>(
|
||||
operationId: OperationId,
|
||||
@@ -73,18 +218,15 @@ export type FeatureHttpBinding<
|
||||
/**
|
||||
* Capability-specific feature binding for the installed HTTP runtime.
|
||||
*
|
||||
* The platform owns transport/outcome normalization. A feature contributes only
|
||||
* operation identity, route identity, typed input and its wire-to-domain mapper.
|
||||
* This keeps HTTP lifecycle/failure semantics out of feature application code
|
||||
* without collapsing storage/realtime/transfer into a universal repository.
|
||||
* The installed contract is the single type authority. A feature contributes
|
||||
* only a route identity plus wire-to-domain/problem interpretation. Transport,
|
||||
* retry, validation and effect-certainty stay in the reusable HTTP capability.
|
||||
*/
|
||||
export function createFeatureHttpBinding<
|
||||
const Operations extends Readonly<
|
||||
Record<string, FeatureHttpOperation<unknown, unknown>>
|
||||
>,
|
||||
const Operations extends Readonly<Record<string, FeatureHttpOperationShape>>,
|
||||
>(
|
||||
executor: InstalledHttpOperationExecutor,
|
||||
operations: Operations,
|
||||
operations: Operations & ValidOperationRegistry<Operations>,
|
||||
): FeatureHttpBinding<Operations> {
|
||||
for (const [registryId, operation] of Object.entries(operations)) {
|
||||
if (registryId !== operation.operationId) {
|
||||
@@ -104,28 +246,47 @@ export function createFeatureHttpBinding<
|
||||
}> = {},
|
||||
): Promise<Result<OperationValue<Operations[OperationId]>, ApiFailure>> {
|
||||
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,
|
||||
...(context.signal === undefined ? {} : { signal: context.signal }),
|
||||
...(context.intent === undefined ? {} : { intent: context.intent }),
|
||||
});
|
||||
return projectExecutionOutcome<
|
||||
OperationValue<Operations[OperationId]>
|
||||
>(
|
||||
return projectExecutionOutcome(
|
||||
operation.operationId,
|
||||
operation.mapSuccess as (
|
||||
value: unknown,
|
||||
) => MappingResult<OperationValue<Operations[OperationId]>>,
|
||||
operation.mapSuccess as (value: WireOutput) => MappingResult<Value>,
|
||||
operation.mapProblem as
|
||||
| ((
|
||||
problem: Problem,
|
||||
metadata: SafeResponseMetadata,
|
||||
) => FeatureHttpProblemMapping | undefined)
|
||||
| undefined,
|
||||
outcome,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function projectExecutionOutcome<Value>(
|
||||
function projectExecutionOutcome<WireOutput, Problem, Value>(
|
||||
operationId: string,
|
||||
mapSuccess: (value: unknown) => MappingResult<Value>,
|
||||
outcome: HttpExecutionOutcome<unknown, unknown>,
|
||||
mapSuccess: (value: WireOutput) => MappingResult<Value>,
|
||||
mapProblem:
|
||||
| ((
|
||||
problem: Problem,
|
||||
metadata: SafeResponseMetadata,
|
||||
) => FeatureHttpProblemMapping | undefined)
|
||||
| undefined,
|
||||
outcome: HttpExecutionOutcome<WireOutput, Problem>,
|
||||
): Result<Value, ApiFailure> {
|
||||
switch (outcome.kind) {
|
||||
case "SUCCESS": {
|
||||
@@ -139,13 +300,15 @@ function projectExecutionOutcome<Value>(
|
||||
{ effect: outcome.effect },
|
||||
);
|
||||
}
|
||||
case "PROBLEM":
|
||||
case "PROBLEM": {
|
||||
const mapped = mapProblem?.(outcome.problem, outcome.metadata);
|
||||
return failure(
|
||||
kindForStatus(outcome.metadata.status),
|
||||
mapped?.kind ?? kindForStatus(outcome.metadata.status),
|
||||
operationId,
|
||||
"CONTRACT_PROBLEM",
|
||||
mapped?.code ?? "CONTRACT_PROBLEM",
|
||||
{ httpStatus: outcome.metadata.status, effect: outcome.effect },
|
||||
);
|
||||
}
|
||||
case "UNAUTHENTICATED":
|
||||
return failure("AUTH_REQUIRED", operationId, "UNAUTHENTICATED", {
|
||||
effect: outcome.effect,
|
||||
|
||||
@@ -22,8 +22,14 @@ export {
|
||||
export {
|
||||
createFeatureHttpBinding,
|
||||
defineFeatureHttpOperation,
|
||||
defineFeatureHttpOperationForRoutes,
|
||||
type FeatureHttpBinding,
|
||||
type FeatureHttpOperation,
|
||||
type FeatureHttpProblemMapping,
|
||||
type HttpContractInput,
|
||||
type HttpContractOperationId,
|
||||
type HttpContractProblem,
|
||||
type HttpContractWireOutput,
|
||||
type InstalledHttpOperationExecutor,
|
||||
} from "./feature-http-binding.ts";
|
||||
/** V3 `attachCredentials` 콜백이 반환해야 하는 결과 타입. */
|
||||
|
||||
@@ -21,9 +21,23 @@ export type IndexedDbSynchronizationState =
|
||||
| "CONFIRMED";
|
||||
|
||||
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<{
|
||||
/**
|
||||
* 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";
|
||||
currentVersion: number;
|
||||
targetVersion: number;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createContractHttpExecutor,
|
||||
createHttpClient,
|
||||
type HttpExecutionObservation,
|
||||
type InstalledHttpOperationExecutor,
|
||||
} from "../adapters/http/index.ts";
|
||||
import {
|
||||
attemptBucket,
|
||||
@@ -480,19 +481,13 @@ export async function createRuntimeAdapters(
|
||||
},
|
||||
observe: createHttpObservationProjector({ diagnostics, telemetry }),
|
||||
});
|
||||
const contractOperations = Object.freeze({
|
||||
async execute(
|
||||
operationId: string,
|
||||
input: unknown,
|
||||
executionContext: Readonly<{
|
||||
routeId: string;
|
||||
signal?: AbortSignal;
|
||||
intent?: MutationIntent;
|
||||
}>,
|
||||
) {
|
||||
const operation =
|
||||
COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(operationId);
|
||||
if (!operation) {
|
||||
const contractOperations: InstalledHttpOperationExecutor = Object.freeze({
|
||||
async execute(operation, input, executionContext) {
|
||||
const installed =
|
||||
COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(
|
||||
operation.contract.operationId,
|
||||
);
|
||||
if (!installed) {
|
||||
return Object.freeze({
|
||||
kind: "CONTRACT_VIOLATION" 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,
|
||||
scope: serverStateScope.getSnapshot(),
|
||||
...(executionContext.signal === undefined
|
||||
@@ -519,7 +518,7 @@ export async function createRuntimeAdapters(
|
||||
},
|
||||
});
|
||||
const featureInputs = createInstalledFeatureInputs({
|
||||
contractOperations,
|
||||
http: contractOperations,
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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 { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
|
||||
|
||||
@@ -6,28 +10,22 @@ const COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS = Object.freeze([
|
||||
REFERENCE_FEATURE_ADAPTER_CONTRIBUTION,
|
||||
] as const);
|
||||
|
||||
type FeatureAdapterContext = Parameters<
|
||||
(typeof COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS)[number]["createInput"]
|
||||
>[0];
|
||||
|
||||
type InstalledFeatureInputs = Readonly<Partial<ApplicationFeatureInputs>>;
|
||||
type InstalledFeatureCapabilityContext =
|
||||
ContributionsCapabilityContext<
|
||||
typeof COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS
|
||||
>;
|
||||
|
||||
/**
|
||||
* Central adapter composition only selects and aggregates feature-owned
|
||||
* contributions. The feature owns how its application input is bound to
|
||||
* reusable platform capabilities.
|
||||
* contributions. The feature owns how its application input is bound to the
|
||||
* reusable platform capabilities declared by its `needs` list.
|
||||
*/
|
||||
export function createInstalledFeatureInputs(
|
||||
context: FeatureAdapterContext,
|
||||
context: InstalledFeatureCapabilityContext,
|
||||
): InstalledFeatureInputs {
|
||||
const entries = COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS
|
||||
.filter((contribution) =>
|
||||
INSTALLED_PRODUCT_FEATURE_IDS.includes(contribution.featureId),
|
||||
)
|
||||
.map((contribution) => {
|
||||
const installed = contribution.createInput(context);
|
||||
return [installed.featureId, installed.input] as const;
|
||||
});
|
||||
|
||||
return Object.freeze(Object.fromEntries(entries)) as InstalledFeatureInputs;
|
||||
return composeFeatureAdapterInputs(
|
||||
COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS,
|
||||
INSTALLED_PRODUCT_FEATURE_IDS,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -19,8 +19,10 @@ augmentation으로 `"reference-feature": ReferenceFeatureInput`을 기여하므
|
||||
별도 cast나 runtime shape 확인 없이 정확한 input type을 받는다. 다만 동적 호출로
|
||||
설치되지 않은 ID가 들어오는 경우를 위해 `get`의 runtime guard도 유지한다.
|
||||
|
||||
예측 가능한 실패는 `src/application/result.ts`의 공통
|
||||
`Result<Value, Failure = AppFailure>`로 반환한다. `AppFailure.kind`는
|
||||
예측 가능한 실패는 canonical shared contract인
|
||||
`src/contracts/result.ts`의 `Result<Value, Failure = AppFailure>`로 반환한다.
|
||||
`src/application/result.ts`는 기존 호출자를 위한 deprecated migration shim일
|
||||
뿐 새 코드의 import 경로가 아니다. `AppFailure.kind`는
|
||||
`ERROR_REGISTRY` key에서 파생된 닫힌 vocabulary이며, transport 호환 이름인
|
||||
`ApiFailure`는 같은 type의 alias다.
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ import {
|
||||
createFeatureHttpBinding,
|
||||
type InstalledHttpOperationExecutor,
|
||||
} from "../../../adapters/http/index.ts";
|
||||
import {
|
||||
defineFeatureAdapterContribution,
|
||||
} from "../../feature-adapter-contribution.ts";
|
||||
import { createReferenceFeatureInput } from "../application/reference-feature-api.ts";
|
||||
import {
|
||||
REFERENCE_FEATURE_ID,
|
||||
@@ -17,15 +20,14 @@ export type InstalledContractOperationExecutor =
|
||||
/**
|
||||
* Feature-owned composition seam.
|
||||
*
|
||||
* The feature contributes typed operation descriptors and its application
|
||||
* gateway. HTTP lifecycle/error normalization stays in the reusable capability
|
||||
* binding rather than being repeated by every product feature.
|
||||
* The feature receives only the HTTP capability it declares in `needs`.
|
||||
* HTTP lifecycle/error normalization stays in the reusable capability binding.
|
||||
*/
|
||||
export function createReferenceFeatureInstalledInput(context: Readonly<{
|
||||
contractOperations: InstalledHttpOperationExecutor;
|
||||
http: InstalledHttpOperationExecutor;
|
||||
}>) {
|
||||
const http = createFeatureHttpBinding(
|
||||
context.contractOperations,
|
||||
context.http,
|
||||
REFERENCE_HTTP_OPERATIONS,
|
||||
);
|
||||
|
||||
@@ -35,7 +37,9 @@ export function createReferenceFeatureInstalledInput(context: Readonly<{
|
||||
});
|
||||
}
|
||||
|
||||
export const REFERENCE_FEATURE_ADAPTER_CONTRIBUTION = Object.freeze({
|
||||
featureId: REFERENCE_FEATURE_ID,
|
||||
createInput: createReferenceFeatureInstalledInput,
|
||||
});
|
||||
export const REFERENCE_FEATURE_ADAPTER_CONTRIBUTION =
|
||||
defineFeatureAdapterContribution({
|
||||
featureId: REFERENCE_FEATURE_ID,
|
||||
needs: ["http"] as const,
|
||||
createInput: createReferenceFeatureInstalledInput,
|
||||
});
|
||||
|
||||
@@ -1,40 +1,57 @@
|
||||
import {
|
||||
defineFeatureHttpOperation,
|
||||
defineFeatureHttpOperationForRoutes,
|
||||
type FeatureHttpBinding,
|
||||
} from "../../../adapters/http/index.ts";
|
||||
import type {
|
||||
ReferenceCreateCommand,
|
||||
ReferenceGateway,
|
||||
ReferenceListFilters,
|
||||
} from "../application/reference-feature-api.ts";
|
||||
import type { ReferenceResource } from "../domain/reference-resource.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";
|
||||
|
||||
export type ReferenceFeatureRouteId =
|
||||
keyof typeof REFERENCE_FEATURE_CONTRACT.routes;
|
||||
|
||||
const defineReferenceHttpOperation =
|
||||
defineFeatureHttpOperationForRoutes<ReferenceFeatureRouteId>();
|
||||
|
||||
export const REFERENCE_HTTP_OPERATIONS = Object.freeze({
|
||||
LIST_REFERENCE_RESOURCES: defineFeatureHttpOperation<
|
||||
ReferenceListFilters,
|
||||
readonly ReferenceResource[]
|
||||
>({
|
||||
operationId: "LIST_REFERENCE_RESOURCES",
|
||||
LIST_REFERENCE_RESOURCES: defineReferenceHttpOperation({
|
||||
contract: LIST_REFERENCE_RESOURCES_CONTRACT,
|
||||
routeId: "REFERENCE_RESOURCE_LIST",
|
||||
mapSuccess: mapReferenceResourceListPayload,
|
||||
}),
|
||||
CREATE_REFERENCE_RESOURCE: defineFeatureHttpOperation<
|
||||
ReferenceCreateCommand,
|
||||
ReferenceResource
|
||||
>({
|
||||
operationId: "CREATE_REFERENCE_RESOURCE",
|
||||
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: defineFeatureHttpOperation<
|
||||
Readonly<{ resourceId: string }>,
|
||||
ReferenceResource
|
||||
>({
|
||||
operationId: "GET_REFERENCE_RESOURCE",
|
||||
GET_REFERENCE_RESOURCE: defineReferenceHttpOperation({
|
||||
contract: GET_REFERENCE_RESOURCE_CONTRACT,
|
||||
routeId: "REFERENCE_RESOURCE_DETAIL",
|
||||
mapSuccess: mapReferenceResourcePayload,
|
||||
}),
|
||||
|
||||
+42
-56
@@ -2,17 +2,22 @@ import { z } from "zod";
|
||||
|
||||
import type {
|
||||
CommandEffectDescriptor,
|
||||
HttpRequestProjection,
|
||||
InstalledContractContribution,
|
||||
InstalledHttpContract,
|
||||
RuntimeValidator,
|
||||
} from "../../../contracts/external-contract-runtime.ts";
|
||||
import { REFERENCE_FEATURE_ID } from "./reference-feature-contract.ts";
|
||||
import {
|
||||
createReferenceResourceCommandSchema,
|
||||
referenceProblemSchema,
|
||||
referenceResourceDtoSchema,
|
||||
referenceResourceListQuerySchema,
|
||||
referenceResourceParamsSchema,
|
||||
type ReferenceProblem,
|
||||
} from "./reference-schemas.ts";
|
||||
|
||||
export type { ReferenceProblem } from "./reference-schemas.ts";
|
||||
|
||||
/**
|
||||
* §4.8. The single `TEMPLATE_FIXTURE` contribution. It keeps the reference
|
||||
* HTTP vertical executable as deterministic template data and is excluded from
|
||||
@@ -53,33 +58,10 @@ function zodValidator<T>(
|
||||
});
|
||||
}
|
||||
|
||||
const referenceResourceDto = z
|
||||
.object({
|
||||
id: z.string().min(1).max(120),
|
||||
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();
|
||||
const PROBLEM_VALIDATOR = zodValidator(
|
||||
"ReferenceProblem",
|
||||
referenceProblemSchema,
|
||||
);
|
||||
|
||||
/**
|
||||
* 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({
|
||||
successEffect: "APPLIED_CONFIRMED" as const,
|
||||
classifyProblem({ status }: Readonly<{ status: number; problem: ReferenceProblem }>) {
|
||||
if (status === 400 || status === 409 || status === 422) return "NOT_APPLIED";
|
||||
classifyProblem({
|
||||
status,
|
||||
}: Readonly<{ status: number; problem: ReferenceProblem }>) {
|
||||
if (status === 400 || status === 409 || status === 422) {
|
||||
return "NOT_APPLIED";
|
||||
}
|
||||
return "MAYBE_APPLIED";
|
||||
},
|
||||
});
|
||||
|
||||
const LIST_REFERENCE_RESOURCES: InstalledHttpContract<
|
||||
z.output<typeof referenceResourceListQuerySchema>,
|
||||
readonly z.output<typeof referenceResourceDto>[],
|
||||
ReferenceProblem
|
||||
> = Object.freeze({
|
||||
export const LIST_REFERENCE_RESOURCES_CONTRACT = Object.freeze({
|
||||
contract: Object.freeze({
|
||||
operationId: "LIST_REFERENCE_RESOURCES",
|
||||
method: "GET" as const,
|
||||
@@ -108,7 +90,7 @@ const LIST_REFERENCE_RESOURCES: InstalledHttpContract<
|
||||
),
|
||||
outputValidator: zodValidator(
|
||||
"ReferenceResourceListPayload",
|
||||
z.array(referenceResourceDto).max(100),
|
||||
z.array(referenceResourceDtoSchema).max(100),
|
||||
),
|
||||
problemValidator: PROBLEM_VALIDATOR,
|
||||
acceptedStatuses: Object.freeze([200]),
|
||||
@@ -139,13 +121,13 @@ const LIST_REFERENCE_RESOURCES: InstalledHttpContract<
|
||||
authProfileId: "REFERENCE_EXTERNAL_BEARER",
|
||||
diagnosticsOperation: "reference.list",
|
||||
}),
|
||||
});
|
||||
|
||||
const GET_REFERENCE_RESOURCE: InstalledHttpContract<
|
||||
z.output<typeof referenceResourceParamsSchema>,
|
||||
z.output<typeof referenceResourceDto>,
|
||||
}) satisfies InstalledHttpContract<
|
||||
z.output<typeof referenceResourceListQuerySchema>,
|
||||
readonly z.output<typeof referenceResourceDtoSchema>[],
|
||||
ReferenceProblem
|
||||
> = Object.freeze({
|
||||
>;
|
||||
|
||||
export const GET_REFERENCE_RESOURCE_CONTRACT = Object.freeze({
|
||||
contract: Object.freeze({
|
||||
operationId: "GET_REFERENCE_RESOURCE",
|
||||
method: "GET" as const,
|
||||
@@ -156,7 +138,7 @@ const GET_REFERENCE_RESOURCE: InstalledHttpContract<
|
||||
),
|
||||
outputValidator: zodValidator(
|
||||
"ReferenceResourcePayload",
|
||||
referenceResourceDto,
|
||||
referenceResourceDtoSchema,
|
||||
),
|
||||
problemValidator: PROBLEM_VALIDATOR,
|
||||
acceptedStatuses: Object.freeze([200]),
|
||||
@@ -183,24 +165,24 @@ const GET_REFERENCE_RESOURCE: InstalledHttpContract<
|
||||
authProfileId: "REFERENCE_EXTERNAL_BEARER",
|
||||
diagnosticsOperation: "reference.detail",
|
||||
}),
|
||||
});
|
||||
|
||||
const CREATE_REFERENCE_RESOURCE: InstalledHttpContract<
|
||||
z.output<typeof createCommandSchema>,
|
||||
z.output<typeof referenceResourceDto>,
|
||||
}) satisfies InstalledHttpContract<
|
||||
z.output<typeof referenceResourceParamsSchema>,
|
||||
z.output<typeof referenceResourceDtoSchema>,
|
||||
ReferenceProblem
|
||||
> = Object.freeze({
|
||||
>;
|
||||
|
||||
export const CREATE_REFERENCE_RESOURCE_CONTRACT = Object.freeze({
|
||||
contract: Object.freeze({
|
||||
operationId: "CREATE_REFERENCE_RESOURCE",
|
||||
method: "POST" as const,
|
||||
pathTemplate: "/api/reference-resources",
|
||||
inputValidator: zodValidator(
|
||||
"CreateReferenceResourceCommand",
|
||||
createCommandSchema,
|
||||
createReferenceResourceCommandSchema,
|
||||
),
|
||||
outputValidator: zodValidator(
|
||||
"ReferenceResourcePayload",
|
||||
referenceResourceDto,
|
||||
referenceResourceDtoSchema,
|
||||
),
|
||||
problemValidator: PROBLEM_VALIDATOR,
|
||||
acceptedStatuses: Object.freeze([200, 201]),
|
||||
@@ -213,7 +195,7 @@ const CREATE_REFERENCE_RESOURCE: InstalledHttpContract<
|
||||
operationIdentityField: "idempotencyKey",
|
||||
}),
|
||||
commandEffect: CREATE_EFFECT,
|
||||
projectRequest(input: z.output<typeof createCommandSchema>) {
|
||||
projectRequest(input: z.output<typeof createReferenceResourceCommandSchema>) {
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({}),
|
||||
queryEntries: Object.freeze([]),
|
||||
@@ -235,7 +217,11 @@ const CREATE_REFERENCE_RESOURCE: InstalledHttpContract<
|
||||
authProfileId: "REFERENCE_EXTERNAL_BEARER",
|
||||
diagnosticsOperation: "reference.create",
|
||||
}),
|
||||
});
|
||||
}) satisfies InstalledHttpContract<
|
||||
z.output<typeof createReferenceResourceCommandSchema>,
|
||||
z.output<typeof referenceResourceDtoSchema>,
|
||||
ReferenceProblem
|
||||
>;
|
||||
|
||||
export const REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION: InstalledContractContribution =
|
||||
Object.freeze({
|
||||
@@ -247,9 +233,9 @@ export const REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION: InstalledContractContribut
|
||||
revision: 1 as const,
|
||||
}),
|
||||
http: Object.freeze([
|
||||
LIST_REFERENCE_RESOURCES,
|
||||
GET_REFERENCE_RESOURCE,
|
||||
CREATE_REFERENCE_RESOURCE,
|
||||
LIST_REFERENCE_RESOURCES_CONTRACT,
|
||||
GET_REFERENCE_RESOURCE_CONTRACT,
|
||||
CREATE_REFERENCE_RESOURCE_CONTRACT,
|
||||
]) as readonly InstalledHttpContract<unknown, unknown, unknown>[],
|
||||
events: Object.freeze([]),
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type InstalledBoundaryMapper,
|
||||
type MappingResult,
|
||||
} from "../../../contracts/boundary-mapper.ts";
|
||||
import type { ReferenceResourceDto } from "./reference-schemas.ts";
|
||||
|
||||
export type ReferenceResourceView = Readonly<{
|
||||
resourceId: string;
|
||||
@@ -16,22 +17,19 @@ export type ReferenceResourceView = Readonly<{
|
||||
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(
|
||||
value: unknown,
|
||||
dto: ReferenceResourceDto,
|
||||
): 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 {
|
||||
return mappingSuccess(
|
||||
createReferenceResource({
|
||||
id: dto.id,
|
||||
displayName: dto.name,
|
||||
createdAt: typeof dto.createdAt === "string" ? dto.createdAt : null,
|
||||
createdAt: dto.createdAt ?? null,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
@@ -40,14 +38,8 @@ export function mapReferenceResourcePayload(
|
||||
}
|
||||
|
||||
export function mapReferenceResourceListPayload(
|
||||
payload: unknown,
|
||||
payload: readonly ReferenceResourceDto[],
|
||||
): MappingResult<readonly ReferenceResource[]> {
|
||||
if (!Array.isArray(payload)) {
|
||||
return mappingFailure("MAPPING_INVARIANT_REJECTED");
|
||||
}
|
||||
if (payload.length > 100) {
|
||||
return mappingFailure("OUTPUT_LIMIT_EXCEEDED");
|
||||
}
|
||||
const output: ReferenceResource[] = [];
|
||||
for (const item of payload) {
|
||||
const mapped = mapReferenceResourcePayload(item);
|
||||
@@ -57,22 +49,58 @@ export function mapReferenceResourceListPayload(
|
||||
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
|
||||
* operation-specific mapper functions above so their result type is exact.
|
||||
* Registry-facing compatibility projection. The registry is intentionally
|
||||
* untyped, so its adapter performs the defensive shape check. Feature HTTP
|
||||
* bindings use the typed operation-specific mappers above.
|
||||
*/
|
||||
export function mapReferenceOperation(
|
||||
operationId: string,
|
||||
payload: unknown,
|
||||
): MappingResult<ReferenceResource | readonly ReferenceResource[]> {
|
||||
if (operationId === "LIST_REFERENCE_RESOURCES") {
|
||||
return mapReferenceResourceListPayload(payload);
|
||||
return mapUnknownReferenceResourceListPayload(payload);
|
||||
}
|
||||
if (
|
||||
operationId === "CREATE_REFERENCE_RESOURCE" ||
|
||||
operationId === "GET_REFERENCE_RESOURCE"
|
||||
) {
|
||||
return mapReferenceResourcePayload(payload);
|
||||
return mapUnknownReferenceResourcePayload(payload);
|
||||
}
|
||||
return mappingFailure("MAPPING_INVARIANT_REJECTED");
|
||||
}
|
||||
@@ -85,7 +113,7 @@ export const REFERENCE_BOUNDARY_MAPPERS = Object.freeze({
|
||||
outputContractId: "ReferenceResourceList",
|
||||
owner: "feature-frontend-reference-feature-vertical-slice",
|
||||
maxOutputItems: 100,
|
||||
map: mapReferenceResourceListPayload,
|
||||
map: mapUnknownReferenceResourceListPayload,
|
||||
}),
|
||||
ReferenceResourceMapper: Object.freeze({
|
||||
mapperId: "ReferenceResourceMapper",
|
||||
@@ -94,7 +122,7 @@ export const REFERENCE_BOUNDARY_MAPPERS = Object.freeze({
|
||||
outputContractId: "ReferenceResource",
|
||||
owner: "feature-frontend-reference-feature-vertical-slice",
|
||||
maxOutputItems: 1,
|
||||
map: mapReferenceResourcePayload,
|
||||
map: mapUnknownReferenceResourcePayload,
|
||||
}),
|
||||
} satisfies Readonly<Record<string, InstalledBoundaryMapper>>);
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export const referenceResourceListQuerySchema = z
|
||||
: Array.isArray(value)
|
||||
? value
|
||||
: [value],
|
||||
z.array(z.string().trim().min(1)),
|
||||
z.array(z.string().trim().min(1)).readonly(),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
@@ -25,7 +25,7 @@ export const referenceResourceParamsSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const referenceResourceDtoSchema = z
|
||||
export const referenceResourceDtoSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).max(120),
|
||||
name: z.string().min(1).max(240),
|
||||
@@ -33,6 +33,28 @@ const referenceResourceDtoSchema = z
|
||||
})
|
||||
.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 = {
|
||||
ReferenceResourceListPayload: z.array(referenceResourceDtoSchema).max(100),
|
||||
ReferenceResourcePayload: referenceResourceDtoSchema,
|
||||
@@ -42,12 +64,7 @@ const requestSchemas = {
|
||||
ReferenceResourceParams: referenceResourceParamsSchema,
|
||||
ReferenceResourceListQuery: referenceResourceListQuerySchema,
|
||||
NoRequest: z.object({}).strict(),
|
||||
CreateReferenceResourceCommand: z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
note: z.string().trim().max(500).optional(),
|
||||
})
|
||||
.strict(),
|
||||
CreateReferenceResourceCommand: createReferenceResourceCommandSchema,
|
||||
} satisfies Record<string, z.ZodType>;
|
||||
|
||||
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)}`;
|
||||
}
|
||||
|
||||
async function installLifecycleRuntime(
|
||||
async function startLifecycleRuntime(
|
||||
page: Page,
|
||||
name: string,
|
||||
targetVersion: 1 | 2,
|
||||
blockedTimeoutMs = 1_000,
|
||||
) {
|
||||
return await page.evaluate(
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
async ({
|
||||
currentDatabaseName,
|
||||
currentTargetVersion,
|
||||
@@ -173,8 +173,12 @@ async function installLifecycleRuntime(
|
||||
__indexedDbLifecycleRuntime?: unknown;
|
||||
}
|
||||
).__indexedDbLifecycleRuntime = runtime;
|
||||
const opened = await runtime.open();
|
||||
return { opened, status: runtime.getStatus() };
|
||||
const opening = runtime.open();
|
||||
(
|
||||
globalThis as unknown as {
|
||||
__indexedDbLifecycleOpening?: Promise<unknown>;
|
||||
}
|
||||
).__indexedDbLifecycleOpening = opening;
|
||||
},
|
||||
{
|
||||
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> {
|
||||
return await page.evaluate(() => {
|
||||
const runtime = (
|
||||
@@ -977,12 +1010,16 @@ test("fails a blocked v2 upgrade closed, then closes its late connection after t
|
||||
);
|
||||
expect(blocker).toBe("OPEN");
|
||||
|
||||
const blocked = await installLifecycleRuntime(
|
||||
page,
|
||||
name,
|
||||
2,
|
||||
25,
|
||||
);
|
||||
await startLifecycleRuntime(page, name, 2, 500);
|
||||
await expect
|
||||
.poll(async () => await lifecycleStatus(page))
|
||||
.toEqual({
|
||||
kind: "BLOCKED",
|
||||
currentVersion: 1,
|
||||
targetVersion: 2,
|
||||
});
|
||||
|
||||
const blocked = await settleLifecycleRuntime(page);
|
||||
expect(blocked).toMatchObject({
|
||||
opened: {
|
||||
ok: false,
|
||||
@@ -992,9 +1029,8 @@ test("fails a blocked v2 upgrade closed, then closes its late connection after t
|
||||
},
|
||||
},
|
||||
status: {
|
||||
kind: "BLOCKED",
|
||||
currentVersion: 1,
|
||||
targetVersion: 2,
|
||||
kind: "CLOSED",
|
||||
reason: "NOT_OPENED",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
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,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const observation = await page.evaluate(async () => {
|
||||
const modulePath =
|
||||
"/src/adapters/browser-file-storage/storage-manager-adapter.ts";
|
||||
const { createStorageDurabilityAdapter } = await import(
|
||||
/* @vite-ignore */ modulePath
|
||||
);
|
||||
const hasStorageManager = navigator.storage !== undefined;
|
||||
const adapter = createStorageDurabilityAdapter(navigator.storage);
|
||||
return adapter.inspect();
|
||||
return {
|
||||
hasStorageManager,
|
||||
result: await adapter.inspect(),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
if (!observation.hasStorageManager) {
|
||||
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(
|
||||
result.value.pressure,
|
||||
observation.result.value.pressure,
|
||||
);
|
||||
expect(
|
||||
result.value.usageBytes === null || result.value.usageBytes >= 0,
|
||||
observation.result.value.usageBytes === null ||
|
||||
observation.result.value.usageBytes >= 0,
|
||||
).toBe(true);
|
||||
expect(
|
||||
result.value.quotaBytes === null || result.value.quotaBytes >= 0,
|
||||
observation.result.value.quotaBytes === null ||
|
||||
observation.result.value.quotaBytes >= 0,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -71,7 +71,9 @@ describe("generic application router", () => {
|
||||
await screen.findByRole("heading", { name: "UI 구성요소", level: 1 }),
|
||||
).toBeVisible();
|
||||
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(() =>
|
||||
expect(
|
||||
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 {
|
||||
createFeatureHttpBinding,
|
||||
defineFeatureHttpOperation,
|
||||
type HttpExecutionOutcome,
|
||||
type InstalledHttpOperationExecutor,
|
||||
} from "../../../src/adapters/http/index.ts";
|
||||
import {
|
||||
mappingFailure,
|
||||
mappingSuccess,
|
||||
} 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 }>;
|
||||
|
||||
const OPERATIONS = Object.freeze({
|
||||
LOAD_RESOURCE: defineFeatureHttpOperation<
|
||||
Readonly<{ resourceId: string }>,
|
||||
Resource
|
||||
>({
|
||||
function validator<T>(schemaId: string): RuntimeValidator<T> {
|
||||
return Object.freeze({
|
||||
schemaId,
|
||||
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",
|
||||
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",
|
||||
mapSuccess(value) {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
typeof (value as Record<string, unknown>).id !== "string" ||
|
||||
typeof (value as Record<string, unknown>).title !== "string"
|
||||
) {
|
||||
if (value.title.length === 0) {
|
||||
return mappingFailure("MAPPING_INVARIANT_REJECTED");
|
||||
}
|
||||
const candidate = value as Readonly<{ id: string; title: string }>;
|
||||
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);
|
||||
|
||||
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", () => {
|
||||
it("keeps typed feature input while platform owns route/context execution", async () => {
|
||||
const execute = vi.fn<InstalledHttpOperationExecutor["execute"]>(
|
||||
async (_operationId, input, context) => {
|
||||
expect(input).toEqual({ resourceId: "resource-1" });
|
||||
expect(context.routeId).toBe("RESOURCE_DETAIL");
|
||||
return Object.freeze({
|
||||
kind: "SUCCESS" 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,
|
||||
it("keeps contract-derived input while platform owns route/context execution", async () => {
|
||||
const scripted = scriptedExecutor<ResourceWire, ResourceProblem>(
|
||||
Object.freeze({
|
||||
kind: "SUCCESS" as const,
|
||||
value: Object.freeze({ id: "resource-1", title: "Reference" }),
|
||||
metadata: Object.freeze({ status: 200 }),
|
||||
effect: "NOT_APPLICABLE" as const,
|
||||
}),
|
||||
);
|
||||
const binding = createFeatureHttpBinding(scripted.executor, OPERATIONS);
|
||||
|
||||
const result = await binding.execute("LOAD_RESOURCE", {
|
||||
resourceId: "resource-1",
|
||||
@@ -63,23 +135,27 @@ describe("feature HTTP binding", () => {
|
||||
ok: true,
|
||||
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 () => {
|
||||
const executor: InstalledHttpOperationExecutor = Object.freeze({
|
||||
async execute() {
|
||||
return Object.freeze({
|
||||
kind: "TRANSPORT_FAILURE" as const,
|
||||
failure: Object.freeze({
|
||||
kind: "TIMEOUT" as const,
|
||||
retryable: true,
|
||||
}),
|
||||
effect: "NOT_STARTED" as const,
|
||||
});
|
||||
},
|
||||
});
|
||||
const binding = createFeatureHttpBinding(executor, OPERATIONS);
|
||||
const scripted = scriptedExecutor<ResourceWire, ResourceProblem>(
|
||||
Object.freeze({
|
||||
kind: "TRANSPORT_FAILURE" as const,
|
||||
failure: Object.freeze({
|
||||
kind: "TIMEOUT" as const,
|
||||
retryable: true,
|
||||
}),
|
||||
effect: "NOT_STARTED" as const,
|
||||
}),
|
||||
);
|
||||
const binding = createFeatureHttpBinding(scripted.executor, OPERATIONS);
|
||||
|
||||
const result = await binding.execute("LOAD_RESOURCE", {
|
||||
resourceId: "resource-1",
|
||||
@@ -92,18 +168,16 @@ describe("feature HTTP binding", () => {
|
||||
expect(result.error.effect).toBe("NOT_STARTED");
|
||||
});
|
||||
|
||||
it("turns feature mapper rejection into the shared mapping failure", async () => {
|
||||
const executor: InstalledHttpOperationExecutor = Object.freeze({
|
||||
async execute() {
|
||||
return Object.freeze({
|
||||
kind: "SUCCESS" as const,
|
||||
value: Object.freeze({ unexpected: true }),
|
||||
metadata: Object.freeze({ status: 200 }),
|
||||
effect: "NOT_APPLICABLE" as const,
|
||||
});
|
||||
},
|
||||
});
|
||||
const binding = createFeatureHttpBinding(executor, OPERATIONS);
|
||||
it("turns a domain mapper rejection into the shared mapping failure", async () => {
|
||||
const scripted = scriptedExecutor<ResourceWire, ResourceProblem>(
|
||||
Object.freeze({
|
||||
kind: "SUCCESS" as const,
|
||||
value: Object.freeze({ id: "resource-1", title: "" }),
|
||||
metadata: Object.freeze({ status: 200 }),
|
||||
effect: "NOT_APPLICABLE" as const,
|
||||
}),
|
||||
);
|
||||
const binding = createFeatureHttpBinding(scripted.executor, OPERATIONS);
|
||||
|
||||
const result = await binding.execute("LOAD_RESOURCE", {
|
||||
resourceId: "resource-1",
|
||||
@@ -114,4 +188,29 @@ describe("feature HTTP binding", () => {
|
||||
expect(result.error.kind).toBe("MAPPING_CONTRACT_VIOLATION");
|
||||
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,
|
||||
}));
|
||||
const installed = createReferenceFeatureInstalledInput({
|
||||
contractOperations: { execute },
|
||||
http: { execute },
|
||||
});
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -3,7 +3,6 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { createContractHttpExecutor } from "../../../src/adapters/http/index.ts";
|
||||
import { createHttpObservationProjector } from "../../../src/bootstrap/runtime-adapters.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() {
|
||||
return Object.freeze({
|
||||
@@ -34,17 +33,9 @@ describe("reference feature HTTP diagnostics", () => {
|
||||
telemetry: { emit: vi.fn() },
|
||||
}),
|
||||
});
|
||||
const operations = new Map(
|
||||
REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION.http.map((operation) => [
|
||||
operation.contract.operationId,
|
||||
operation,
|
||||
]),
|
||||
);
|
||||
const installed = createReferenceFeatureInstalledInput({
|
||||
contractOperations: Object.freeze({
|
||||
async execute(operationId, input, context) {
|
||||
const operation = operations.get(operationId);
|
||||
if (!operation) throw new Error("Unregistered reference operation");
|
||||
http: Object.freeze({
|
||||
async execute(operation, input, context) {
|
||||
return contractHttp.execute(operation, input, {
|
||||
routeId: context.routeId,
|
||||
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 {
|
||||
type InstalledHttpOperationExecutor,
|
||||
} from "../../../src/adapters/http/index.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", () => {
|
||||
it("preserves the feature route id through the installed operation executor", async () => {
|
||||
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({
|
||||
contractOperations: Object.freeze({
|
||||
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,
|
||||
});
|
||||
},
|
||||
}),
|
||||
http: Object.freeze({ execute }),
|
||||
});
|
||||
|
||||
await installed.input.listResources({ limit: 20 });
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@
|
||||
"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 }
|
||||
},
|
||||
"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 }
|
||||
},
|
||||
"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,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"files": [
|
||||
"./invalid-async-overlay.ts"
|
||||
]
|
||||
}
|
||||
+15
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"files": [
|
||||
"./invalid-message-key.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"files": [
|
||||
"./invalid-message-params.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"files": [
|
||||
"./invalid-page-action.tsx"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"files": [
|
||||
"./invalid-route-runtime.ts"
|
||||
]
|
||||
}
|
||||
+27
-20
@@ -25,6 +25,13 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
* longer. Raising the global default instead would hide a genuinely hung test.
|
||||
*/
|
||||
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[] = [];
|
||||
|
||||
@@ -216,7 +223,7 @@ describe("provider guardian transaction protocol", () => {
|
||||
}));
|
||||
|
||||
const ready = decodeProviderGuardianReady(
|
||||
await within(readyPayload, 1_000, "guardian READY"),
|
||||
await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian READY"),
|
||||
nonce,
|
||||
);
|
||||
const raw = await lstat(rawPath);
|
||||
@@ -231,7 +238,7 @@ describe("provider guardian transaction protocol", () => {
|
||||
.toEqual({ dev: ready.sealedDev, ino: ready.sealedIno, mode: 0o600 });
|
||||
|
||||
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,
|
||||
signal: null,
|
||||
});
|
||||
@@ -254,7 +261,7 @@ describe("provider guardian transaction protocol", () => {
|
||||
if (input) child.stdin!.end(input);
|
||||
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,
|
||||
signal: null,
|
||||
});
|
||||
@@ -289,7 +296,7 @@ describe("provider guardian transaction protocol", () => {
|
||||
|
||||
await expect(within(
|
||||
waitForChild(fixture.child),
|
||||
1_000,
|
||||
PROCESS_HANDSHAKE_WATCHDOG_MS,
|
||||
`${boundKind} partial bootstrap exit`,
|
||||
)).resolves.toEqual({ code: 126, signal: null });
|
||||
const boundPath = boundKind === "raw" ? fixture.rawStagingPath : fixture.sealedTempPath;
|
||||
@@ -320,7 +327,7 @@ describe("provider guardian transaction protocol", () => {
|
||||
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 });
|
||||
await expect(readdir(path.join(workspace, "provider-evidence/untrusted"))).resolves.toEqual([]);
|
||||
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,
|
||||
}));
|
||||
const ready = decodeProviderGuardianReady(
|
||||
await within(readyPayload, 1_000, "guardian READY"),
|
||||
await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian READY"),
|
||||
nonce,
|
||||
);
|
||||
const sealedTempPath = path.join(evidenceRoot, ready.sealedTempLeaf);
|
||||
@@ -379,7 +386,7 @@ describe("provider guardian transaction protocol", () => {
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
}));
|
||||
decodeProviderGuardianPublished(
|
||||
await within(publishedPayload, 1_000, "guardian PUBLISHED"),
|
||||
await within(publishedPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian PUBLISHED"),
|
||||
nonce,
|
||||
{ dev: ready.sealedDev, ino: ready.sealedIno },
|
||||
);
|
||||
@@ -393,7 +400,7 @@ describe("provider guardian transaction protocol", () => {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 50));
|
||||
expect(child.exitCode).toBeNull();
|
||||
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,
|
||||
signal: null,
|
||||
});
|
||||
@@ -456,7 +463,7 @@ describe("provider guardian transaction protocol", () => {
|
||||
const transaction = await establishPublishedGuardian(workspace, Buffer.alloc(32, 0x31));
|
||||
|
||||
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 });
|
||||
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!.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 });
|
||||
await assertTransactionAbsent(transaction);
|
||||
});
|
||||
@@ -505,7 +512,7 @@ describe("provider guardian transaction protocol", () => {
|
||||
});
|
||||
|
||||
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) }));
|
||||
await lease.abort();
|
||||
await expect(lstat(lease.rawPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
@@ -536,8 +543,8 @@ describe("provider guardian transaction protocol", () => {
|
||||
});
|
||||
process.kill(guardianPid, "SIGCONT");
|
||||
|
||||
await within(rawLink, 1_000, "pre-READY canonical raw link");
|
||||
await expect(within(starting, 2_000, "pre-READY guardian rejection"))
|
||||
await within(rawLink, PROCESS_HANDSHAKE_WATCHDOG_MS, "pre-READY canonical raw link");
|
||||
await expect(within(starting, PROCESS_HANDSHAKE_WATCHDOG_MS, "pre-READY guardian rejection"))
|
||||
.rejects.toThrow(/provider guardian/u);
|
||||
const possibleTempLeaves = (await readdir(evidenceRoot)).filter((leaf) =>
|
||||
leaf.startsWith(".vulnerability-report.json.guardian-")
|
||||
@@ -587,7 +594,7 @@ describe("provider guardian transaction protocol", () => {
|
||||
await canaryHandle.close();
|
||||
|
||||
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);
|
||||
expect(await readFile(rawPath)).toEqual(canaryBytes);
|
||||
expect(await lstat(rawPath)).toMatchObject({
|
||||
@@ -664,14 +671,14 @@ describe("provider guardian transaction protocol", () => {
|
||||
deadlineEpochMs: Date.now() + 3_000,
|
||||
}));
|
||||
const ready = decodeProviderGuardianReady(
|
||||
await within(readyPayload, 1_000, "closed-stderr guardian READY"),
|
||||
await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "closed-stderr guardian READY"),
|
||||
nonce,
|
||||
);
|
||||
child.stderr!.destroy();
|
||||
await new Promise<void>((resolve) => child.stderr!.once("close", resolve));
|
||||
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 });
|
||||
await expect(lstat(path.join(rawDirectory, "vulnerability-report.json")))
|
||||
.rejects.toMatchObject({ code: "ENOENT" });
|
||||
@@ -697,12 +704,12 @@ describe("provider guardian transaction protocol", () => {
|
||||
deadlineEpochMs: Date.now() + 350,
|
||||
}));
|
||||
const ready = decodeProviderGuardianReady(
|
||||
await within(readyPayload, 1_000, "deadline guardian READY"),
|
||||
await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "deadline guardian READY"),
|
||||
nonce,
|
||||
);
|
||||
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" });
|
||||
await expect(lstat(rawPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(lstat(sealedTempPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
@@ -874,7 +881,7 @@ async function establishPublishedGuardian(
|
||||
deadlineEpochMs: Date.now() + 3_000,
|
||||
}));
|
||||
const ready = decodeProviderGuardianReady(
|
||||
await within(readyPayload, 1_000, "guardian READY"),
|
||||
await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian READY"),
|
||||
nonce,
|
||||
);
|
||||
const sealedTempPath = path.join(evidenceRoot, ready.sealedTempLeaf);
|
||||
@@ -896,7 +903,7 @@ async function establishPublishedGuardian(
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
}));
|
||||
decodeProviderGuardianPublished(
|
||||
await within(publishedPayload, 1_000, "guardian PUBLISHED"),
|
||||
await within(publishedPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian PUBLISHED"),
|
||||
nonce,
|
||||
{ dev: ready.sealedDev, ino: ready.sealedIno },
|
||||
);
|
||||
@@ -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", () => {
|
||||
expect(decodeJsonBytes(new TextEncoder().encode('{"ok":true}'))).toEqual({
|
||||
ok: true,
|
||||
|
||||
@@ -73,12 +73,17 @@ function labelledFileInput(): HTMLInputElement {
|
||||
describe("browser file pickers", () => {
|
||||
it("treats native input cancellation as a normal dismissed outcome", async () => {
|
||||
const input = labelledFileInput();
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
Object.defineProperty(input, "click", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => {
|
||||
queueMicrotask(() => input.dispatchEvent(new Event("cancel")));
|
||||
}),
|
||||
});
|
||||
const baselineShowPicker = vi.fn();
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
configurable: true,
|
||||
value: baselineShowPicker,
|
||||
});
|
||||
const harness = createHarness();
|
||||
const picker = new NativeInputFilePicker({
|
||||
input,
|
||||
@@ -90,6 +95,7 @@ describe("browser file pickers", () => {
|
||||
ok: true,
|
||||
value: { kind: "DISMISSED" },
|
||||
});
|
||||
expect(baselineShowPicker).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resets the native input and supports same-file reselection", async () => {
|
||||
@@ -102,12 +108,12 @@ describe("browser file pickers", () => {
|
||||
configurable: true,
|
||||
value: [selected],
|
||||
});
|
||||
const showPicker = vi.fn(() => {
|
||||
const click = vi.fn(() => {
|
||||
queueMicrotask(() => input.dispatchEvent(new Event("change")));
|
||||
});
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
Object.defineProperty(input, "click", {
|
||||
configurable: true,
|
||||
value: showPicker,
|
||||
value: click,
|
||||
});
|
||||
let sequence = 0;
|
||||
const { policies, vault } = createHarness(policyDefinition, {
|
||||
@@ -130,14 +136,14 @@ describe("browser file pickers", () => {
|
||||
ok: true,
|
||||
value: { kind: "SELECTED" },
|
||||
});
|
||||
expect(showPicker).toHaveBeenCalledTimes(2);
|
||||
expect(click).toHaveBeenCalledTimes(2);
|
||||
expect(input.value).toBe("");
|
||||
expect(vault.activeReferenceCount).toBe(2);
|
||||
});
|
||||
|
||||
it("snapshots selection policy before awaiting picker events", async () => {
|
||||
const input = labelledFileInput();
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
Object.defineProperty(input, "click", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
@@ -186,7 +192,7 @@ describe("browser file pickers", () => {
|
||||
|
||||
it("honors AbortSignal while a native dialog is pending", async () => {
|
||||
const input = labelledFileInput();
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
Object.defineProperty(input, "click", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
@@ -215,7 +221,7 @@ describe("browser file pickers", () => {
|
||||
let fallback: (() => void) | undefined;
|
||||
let delay: number | undefined;
|
||||
const clearTimeout = vi.fn();
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
Object.defineProperty(input, "click", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => {
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
@@ -261,7 +267,7 @@ describe("browser file pickers", () => {
|
||||
const input = labelledFileInput();
|
||||
let fallback: (() => void) | undefined;
|
||||
const clearTimeout = vi.fn();
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
Object.defineProperty(input, "click", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => {
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
@@ -316,14 +322,14 @@ describe("browser file pickers", () => {
|
||||
setTimeout: originalSetTimeout,
|
||||
clearTimeout: originalClearTimeout,
|
||||
};
|
||||
const originalShowPicker = vi.fn(() => {
|
||||
const originalClick = vi.fn(() => {
|
||||
focus?.(new Event("focus"));
|
||||
});
|
||||
const replacedShowPicker = vi.fn();
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
const replacedClick = vi.fn();
|
||||
Object.defineProperty(input, "click", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalShowPicker,
|
||||
value: originalClick,
|
||||
});
|
||||
const harness = createHarness();
|
||||
const picker = new NativeInputFilePicker({
|
||||
@@ -338,7 +344,7 @@ describe("browser file pickers", () => {
|
||||
focusFallbackGraceMs: 0,
|
||||
});
|
||||
|
||||
input.showPicker = replacedShowPicker;
|
||||
input.click = replacedClick;
|
||||
windowHost.addEventListener = vi.fn();
|
||||
windowHost.removeEventListener = vi.fn();
|
||||
scheduler.setTimeout = vi.fn();
|
||||
@@ -348,8 +354,8 @@ describe("browser file pickers", () => {
|
||||
ok: true,
|
||||
value: { kind: "DISMISSED" },
|
||||
});
|
||||
expect(originalShowPicker).toHaveBeenCalledOnce();
|
||||
expect(replacedShowPicker).not.toHaveBeenCalled();
|
||||
expect(originalClick).toHaveBeenCalledOnce();
|
||||
expect(replacedClick).not.toHaveBeenCalled();
|
||||
expect(originalWindowAdd).toHaveBeenCalledOnce();
|
||||
expect(originalWindowRemove).toHaveBeenCalledOnce();
|
||||
expect(originalSetTimeout).toHaveBeenCalledOnce();
|
||||
|
||||
@@ -178,7 +178,7 @@ describe("browser file runtime hard limits and disposal", () => {
|
||||
|
||||
it("aborts a pending native picker and prevents event resurrection", async () => {
|
||||
const input = fileInput();
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
Object.defineProperty(input, "click", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
|
||||
@@ -189,9 +189,9 @@ describe("CI gate contract", () => {
|
||||
),
|
||||
);
|
||||
expect(contract.jobs).toHaveLength(9);
|
||||
expect(contract.commands).toHaveLength(84);
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(96);
|
||||
expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(23);
|
||||
expect(contract.commands).toHaveLength(91);
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(103);
|
||||
expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(30);
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(88);
|
||||
expect(contract.artifacts).toHaveLength(109);
|
||||
expect(contract.stages).toHaveLength(5);
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "../../src/features/installed-feature-contracts.ts";
|
||||
|
||||
const COMPILED = Object.freeze([
|
||||
Object.freeze({ featureId: "reference-feature" }),
|
||||
Object.freeze({ featureId: "alpha-feature" }),
|
||||
Object.freeze({ featureId: "billing" }),
|
||||
]);
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("build-time product selection", () => {
|
||||
expect(
|
||||
selectCompiledProductFeatures(COMPILED, declared).map((f) => f.featureId),
|
||||
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),
|
||||
).toEqual(["billing"]);
|
||||
expect(
|
||||
selectCompiledProductFeatures(COMPILED, " billing , reference-feature ").map(
|
||||
selectCompiledProductFeatures(COMPILED, " billing , alpha-feature ").map(
|
||||
(f) => f.featureId,
|
||||
),
|
||||
).toEqual(["reference-feature", "billing"]);
|
||||
).toEqual(["alpha-feature", "billing"]);
|
||||
});
|
||||
|
||||
it("selects nothing only when asked explicitly", () => {
|
||||
@@ -79,13 +79,13 @@ describe("build-time product selection", () => {
|
||||
describe("runtime product feature resolution", () => {
|
||||
it("reports active, disabled and not-installed distinctly", () => {
|
||||
const statuses = resolveProductFeatures(
|
||||
["reference-feature", "billing"],
|
||||
["reference-feature"],
|
||||
{ "reference-feature": "DISABLED" },
|
||||
["alpha-feature", "billing"],
|
||||
["alpha-feature"],
|
||||
{ "alpha-feature": "DISABLED" },
|
||||
);
|
||||
expect(statuses).toEqual([
|
||||
{ featureId: "alpha-feature", state: "DISABLED_BY_CONFIG" },
|
||||
{ featureId: "billing", state: "NOT_INSTALLED" },
|
||||
{ featureId: "reference-feature", state: "DISABLED_BY_CONFIG" },
|
||||
]);
|
||||
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
|
||||
// inert rather than fatal.
|
||||
const statuses = resolveProductFeatures(
|
||||
["reference-feature"],
|
||||
["reference-feature"],
|
||||
["alpha-feature"],
|
||||
["alpha-feature"],
|
||||
{ analytics: "DISABLED" },
|
||||
);
|
||||
expect(activeProductFeatureIds(statuses)).toEqual(["reference-feature"]);
|
||||
expect(activeProductFeatureIds(statuses)).toEqual(["alpha-feature"]);
|
||||
});
|
||||
|
||||
it("leaves an installed feature active without an override", () => {
|
||||
@@ -140,15 +140,15 @@ describe("runtime config carries the switch", () => {
|
||||
expect(
|
||||
runtimeConfigV2ArtifactSchema.parse({
|
||||
...base,
|
||||
FEATURE_OVERRIDES: { "reference-feature": "DISABLED" },
|
||||
FEATURE_OVERRIDES: { "alpha-feature": "DISABLED" },
|
||||
}).FEATURE_OVERRIDES,
|
||||
).toEqual({ "reference-feature": "DISABLED" });
|
||||
).toEqual({ "alpha-feature": "DISABLED" });
|
||||
// There is no "ENABLED": the vocabulary itself is what makes the rule
|
||||
// unbreakable, not a check somewhere downstream.
|
||||
expect(
|
||||
runtimeConfigV2ArtifactSchema.safeParse({
|
||||
...base,
|
||||
FEATURE_OVERRIDES: { "reference-feature": "ENABLED" },
|
||||
FEATURE_OVERRIDES: { "alpha-feature": "ENABLED" },
|
||||
}).success,
|
||||
).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);
|
||||
expect(owned.length).toBeGreaterThan(0);
|
||||
for (const routeId of owned) {
|
||||
expect(Object.keys(ROUTE_REGISTRY), routeId).toContain(routeId);
|
||||
}
|
||||
expect(
|
||||
owned.every((routeId) => ROUTE_FEATURE_OWNER[routeId] !== undefined),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -10,135 +10,17 @@ import {
|
||||
type PublicCacheRuntimePolicy,
|
||||
} from "../../src/adapters/cache-storage/public-cache-policy.ts";
|
||||
import {
|
||||
computePublicCacheManifestDigestHex,
|
||||
createPublicResponseCacheAdapter,
|
||||
type PublicCacheMutationLock,
|
||||
} from "../../src/adapters/cache-storage/public-response-cache-adapter.ts";
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
};
|
||||
}
|
||||
import {
|
||||
MemoryCacheStorage,
|
||||
deferred,
|
||||
digestHex,
|
||||
immediateLock,
|
||||
manifestFor,
|
||||
} from "./public-response-cache-fixture.ts";
|
||||
|
||||
describe("public response Cache Storage adapter", () => {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
@@ -32,319 +32,19 @@ import type {
|
||||
} 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";
|
||||
|
||||
type TestCapability = Readonly<{ id: string }>;
|
||||
|
||||
const activeSignal = new AbortController().signal;
|
||||
const noContentionLock: UploadMutationLock = Object.freeze({
|
||||
async run<Value>(
|
||||
_uploadKey: string,
|
||||
_signal: AbortSignal,
|
||||
task: () => Promise<Value>,
|
||||
): Promise<Value> {
|
||||
return await task();
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
import {
|
||||
activeSignal,
|
||||
byteStreamSource,
|
||||
createControlHarness,
|
||||
createMemoryCancellationPair,
|
||||
createSerialMutationLock,
|
||||
executorFor,
|
||||
MemoryCheckpointStore,
|
||||
noContentionLock,
|
||||
rangeSource,
|
||||
runtimePolicy,
|
||||
type TestCapability,
|
||||
} from "./resumable-upload-runtime-fixture.ts";
|
||||
|
||||
describe("production resumable upload runtime", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
const canonical = await loadCiGateContract(process.cwd());
|
||||
expect(canonical.gates).toHaveLength(27);
|
||||
expect(canonical.commands).toHaveLength(84);
|
||||
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(96);
|
||||
expect(canonical.commands).toHaveLength(91);
|
||||
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(103);
|
||||
expect(canonical.artifacts).toHaveLength(109);
|
||||
expect(canonical.stages).toHaveLength(5);
|
||||
expect(canonical.retention.classes).toHaveLength(5);
|
||||
|
||||
Reference in New Issue
Block a user