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",
|
"script": "check:types:fixture:reference-operation",
|
||||||
"expect": "fail",
|
"expect": "fail",
|
||||||
"expectedExitCode": 1,
|
"expectedExitCode": 1,
|
||||||
|
"expectedDiagnosticId": "error TS2741:"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "check-types-fixture-http-operation-input",
|
||||||
|
"script": "check:types:fixture:http-operation-input",
|
||||||
|
"expect": "fail",
|
||||||
|
"expectedExitCode": 1,
|
||||||
|
"expectedDiagnosticId": "error TS2353:"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "check-types-fixture-http-wire-mapper",
|
||||||
|
"script": "check:types:fixture:http-wire-mapper",
|
||||||
|
"expect": "fail",
|
||||||
|
"expectedExitCode": 1,
|
||||||
|
"expectedDiagnosticId": "error TS2322:"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "check-types-fixture-http-operation-id",
|
||||||
|
"script": "check:types:fixture:http-operation-id",
|
||||||
|
"expect": "fail",
|
||||||
|
"expectedExitCode": 1,
|
||||||
|
"expectedDiagnosticId": "error TS2345:"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "check-types-fixture-http-route-id",
|
||||||
|
"script": "check:types:fixture:http-route-id",
|
||||||
|
"expect": "fail",
|
||||||
|
"expectedExitCode": 1,
|
||||||
|
"expectedDiagnosticId": "error TS2322:"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "check-types-fixture-feature-contribution-input",
|
||||||
|
"script": "check:types:fixture:feature-contribution-input",
|
||||||
|
"expect": "fail",
|
||||||
|
"expectedExitCode": 1,
|
||||||
|
"expectedDiagnosticId": "error TS2322:"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "check-types-fixture-feature-capability-selection",
|
||||||
|
"script": "check:types:fixture:feature-capability-selection",
|
||||||
|
"expect": "fail",
|
||||||
|
"expectedExitCode": 1,
|
||||||
|
"expectedDiagnosticId": "error TS2339:"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "check-types-fixture-direct-feature-composition",
|
||||||
|
"script": "check:types:fixture:direct-feature-composition",
|
||||||
|
"expect": "fail",
|
||||||
|
"expectedExitCode": 1,
|
||||||
"expectedDiagnosticId": "error TS2345:"
|
"expectedDiagnosticId": "error TS2345:"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -95,7 +144,7 @@
|
|||||||
"script": "check:types:fixture:route-runtime",
|
"script": "check:types:fixture:route-runtime",
|
||||||
"expect": "fail",
|
"expect": "fail",
|
||||||
"expectedExitCode": 1,
|
"expectedExitCode": 1,
|
||||||
"expectedDiagnosticId": "error TS2353:"
|
"expectedDiagnosticId": "error TS2740:"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "check-types-fixture-page-action",
|
"id": "check-types-fixture-page-action",
|
||||||
@@ -1698,6 +1747,13 @@
|
|||||||
"check-types-fixture-feature-input",
|
"check-types-fixture-feature-input",
|
||||||
"check-types-fixture-failure-kind",
|
"check-types-fixture-failure-kind",
|
||||||
"check-types-fixture-reference-operation",
|
"check-types-fixture-reference-operation",
|
||||||
|
"check-types-fixture-http-operation-input",
|
||||||
|
"check-types-fixture-http-wire-mapper",
|
||||||
|
"check-types-fixture-http-operation-id",
|
||||||
|
"check-types-fixture-http-route-id",
|
||||||
|
"check-types-fixture-feature-contribution-input",
|
||||||
|
"check-types-fixture-feature-capability-selection",
|
||||||
|
"check-types-fixture-direct-feature-composition",
|
||||||
"check-types-fixture-async-overlay",
|
"check-types-fixture-async-overlay",
|
||||||
"check-types-fixture-route-runtime",
|
"check-types-fixture-route-runtime",
|
||||||
"check-types-fixture-page-action",
|
"check-types-fixture-page-action",
|
||||||
|
|||||||
@@ -12,97 +12,192 @@
|
|||||||
{
|
{
|
||||||
"path": "src/adapters/http/bounded-body-reader.ts",
|
"path": "src/adapters/http/bounded-body-reader.ts",
|
||||||
"owner": "http-runtime",
|
"owner": "http-runtime",
|
||||||
"minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 90 }
|
"minimum": {
|
||||||
|
"lines": 95,
|
||||||
|
"statements": 95,
|
||||||
|
"functions": 95,
|
||||||
|
"branches": 90
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/adapters/http/bounded-json.ts",
|
"path": "src/adapters/http/bounded-json.ts",
|
||||||
"owner": "http-runtime",
|
"owner": "http-runtime",
|
||||||
"minimum": { "lines": 85, "statements": 84, "functions": 95, "branches": 78 }
|
"minimum": {
|
||||||
|
"lines": 85,
|
||||||
|
"statements": 84,
|
||||||
|
"functions": 95,
|
||||||
|
"branches": 78
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/adapters/http/http-execution-v3.ts",
|
"path": "src/adapters/http/http-execution-v3.ts",
|
||||||
"owner": "http-runtime",
|
"owner": "http-runtime",
|
||||||
"minimum": { "lines": 75, "statements": 73, "functions": 70, "branches": 52 }
|
"minimum": {
|
||||||
|
"lines": 75,
|
||||||
|
"statements": 73,
|
||||||
|
"functions": 70,
|
||||||
|
"branches": 52
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/adapters/http/request-builder.ts",
|
"path": "src/adapters/http/request-builder.ts",
|
||||||
"owner": "http-runtime",
|
"owner": "http-runtime",
|
||||||
"minimum": { "lines": 85, "statements": 85, "functions": 95, "branches": 82 }
|
"minimum": {
|
||||||
|
"lines": 85,
|
||||||
|
"statements": 85,
|
||||||
|
"functions": 95,
|
||||||
|
"branches": 82
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/adapters/http/retry-policy.ts",
|
"path": "src/adapters/http/retry-policy.ts",
|
||||||
"owner": "http-runtime",
|
"owner": "http-runtime",
|
||||||
"minimum": { "lines": 80, "statements": 78, "functions": 95, "branches": 78 }
|
"minimum": {
|
||||||
|
"lines": 80,
|
||||||
|
"statements": 78,
|
||||||
|
"functions": 95,
|
||||||
|
"branches": 78
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/adapters/query-cache/server-state-scope-runtime.ts",
|
"path": "src/adapters/query-cache/server-state-scope-runtime.ts",
|
||||||
"owner": "server-state-runtime",
|
"owner": "server-state-runtime",
|
||||||
"minimum": { "lines": 85, "statements": 85, "functions": 85, "branches": 75 }
|
"minimum": {
|
||||||
|
"lines": 85,
|
||||||
|
"statements": 85,
|
||||||
|
"functions": 85,
|
||||||
|
"branches": 75
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/adapters/service-worker/service-worker-lifecycle.ts",
|
"path": "src/adapters/service-worker/service-worker-lifecycle.ts",
|
||||||
"owner": "service-worker-runtime",
|
"owner": "service-worker-runtime",
|
||||||
"minimum": { "lines": 64, "statements": 60, "functions": 65, "branches": 43 }
|
"minimum": {
|
||||||
|
"lines": 64,
|
||||||
|
"statements": 60,
|
||||||
|
"functions": 65,
|
||||||
|
"branches": 43
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/adapters/storage/browser-storage-adapter.ts",
|
"path": "src/adapters/storage/browser-storage-adapter.ts",
|
||||||
"owner": "storage-runtime",
|
"owner": "storage-runtime",
|
||||||
"minimum": { "lines": 60, "statements": 60, "functions": 70, "branches": 60 }
|
"minimum": {
|
||||||
|
"lines": 60,
|
||||||
|
"statements": 60,
|
||||||
|
"functions": 70,
|
||||||
|
"branches": 60
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/adapters/telemetry/best-effort-telemetry.ts",
|
"path": "src/adapters/telemetry/best-effort-telemetry.ts",
|
||||||
"owner": "telemetry-runtime",
|
"owner": "telemetry-runtime",
|
||||||
"minimum": { "lines": 85, "statements": 85, "functions": 70, "branches": 75 }
|
"minimum": {
|
||||||
|
"lines": 85,
|
||||||
|
"statements": 85,
|
||||||
|
"functions": 70,
|
||||||
|
"branches": 75
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/application/create-application.ts",
|
"path": "src/application/create-application.ts",
|
||||||
"owner": "application-runtime",
|
"owner": "application-runtime",
|
||||||
"minimum": { "lines": 90, "statements": 90, "functions": 80, "branches": 68 }
|
"minimum": {
|
||||||
|
"lines": 90,
|
||||||
|
"statements": 90,
|
||||||
|
"functions": 80,
|
||||||
|
"branches": 68
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/application/policies/compatibility.ts",
|
"path": "src/contracts/compatibility.ts",
|
||||||
"owner": "application-policy",
|
"owner": "compatibility-contracts",
|
||||||
"minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 75 }
|
"minimum": {
|
||||||
|
"lines": 95,
|
||||||
|
"statements": 95,
|
||||||
|
"functions": 95,
|
||||||
|
"branches": 75
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/application/policies/performance-budgets.ts",
|
"path": "src/application/policies/performance-budgets.ts",
|
||||||
"owner": "application-policy",
|
"owner": "application-policy",
|
||||||
"minimum": { "lines": 80, "statements": 80, "functions": 80, "branches": 40 }
|
"minimum": {
|
||||||
|
"lines": 80,
|
||||||
|
"statements": 80,
|
||||||
|
"functions": 80,
|
||||||
|
"branches": 40
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/application/policies/promotion-readiness.ts",
|
"path": "src/application/policies/promotion-readiness.ts",
|
||||||
"owner": "release-runtime",
|
"owner": "release-runtime",
|
||||||
"minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 95 }
|
"minimum": {
|
||||||
|
"lines": 95,
|
||||||
|
"statements": 95,
|
||||||
|
"functions": 95,
|
||||||
|
"branches": 95
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/application/use-cases/decide-chunk-recovery.ts",
|
"path": "src/application/use-cases/decide-chunk-recovery.ts",
|
||||||
"owner": "application-runtime",
|
"owner": "application-runtime",
|
||||||
"minimum": { "lines": 90, "statements": 90, "functions": 95, "branches": 85 }
|
"minimum": {
|
||||||
|
"lines": 90,
|
||||||
|
"statements": 90,
|
||||||
|
"functions": 95,
|
||||||
|
"branches": 85
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/bootstrap/load-release-manifest.ts",
|
"path": "src/bootstrap/load-release-manifest.ts",
|
||||||
"owner": "release-runtime",
|
"owner": "release-runtime",
|
||||||
"minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 80 }
|
"minimum": {
|
||||||
|
"lines": 90,
|
||||||
|
"statements": 90,
|
||||||
|
"functions": 90,
|
||||||
|
"branches": 80
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/bootstrap/read-bounded-boot-json.ts",
|
"path": "src/bootstrap/read-bounded-boot-json.ts",
|
||||||
"owner": "bootstrap-runtime",
|
"owner": "bootstrap-runtime",
|
||||||
"minimum": { "lines": 71, "statements": 66, "functions": 48, "branches": 57 }
|
"minimum": {
|
||||||
|
"lines": 71,
|
||||||
|
"statements": 66,
|
||||||
|
"functions": 48,
|
||||||
|
"branches": 57
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/contracts/diagnostics.ts",
|
"path": "src/contracts/diagnostics.ts",
|
||||||
"owner": "diagnostics-contracts",
|
"owner": "diagnostics-contracts",
|
||||||
"minimum": { "lines": 68, "statements": 68, "functions": 95, "branches": 58 }
|
"minimum": {
|
||||||
|
"lines": 68,
|
||||||
|
"statements": 68,
|
||||||
|
"functions": 95,
|
||||||
|
"branches": 58
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/features/reference-feature/adapters/reference-http-gateway.ts",
|
"path": "src/features/reference-feature/adapters/reference-http-gateway.ts",
|
||||||
"owner": "reference-feature",
|
"owner": "reference-feature",
|
||||||
"minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 90 }
|
"minimum": {
|
||||||
|
"lines": 90,
|
||||||
|
"statements": 90,
|
||||||
|
"functions": 90,
|
||||||
|
"branches": 90
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "src/presentation/adapters/query/application-query.ts",
|
"path": "src/presentation/adapters/query/application-query.ts",
|
||||||
"owner": "presentation-runtime",
|
"owner": "presentation-runtime",
|
||||||
"minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 80 }
|
"minimum": {
|
||||||
|
"lines": 90,
|
||||||
|
"statements": 90,
|
||||||
|
"functions": 90,
|
||||||
|
"branches": 80
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"highRiskPaths": [
|
"highRiskPaths": [
|
||||||
@@ -116,7 +211,7 @@
|
|||||||
"src/adapters/storage/browser-storage-adapter.ts",
|
"src/adapters/storage/browser-storage-adapter.ts",
|
||||||
"src/adapters/telemetry/best-effort-telemetry.ts",
|
"src/adapters/telemetry/best-effort-telemetry.ts",
|
||||||
"src/application/create-application.ts",
|
"src/application/create-application.ts",
|
||||||
"src/application/policies/compatibility.ts",
|
"src/contracts/compatibility.ts",
|
||||||
"src/application/policies/performance-budgets.ts",
|
"src/application/policies/performance-budgets.ts",
|
||||||
"src/application/policies/promotion-readiness.ts",
|
"src/application/policies/promotion-readiness.ts",
|
||||||
"src/application/use-cases/decide-chunk-recovery.ts",
|
"src/application/use-cases/decide-chunk-recovery.ts",
|
||||||
|
|||||||
@@ -247,6 +247,8 @@ domain, application state, query cache, global store, diagnostics에 넣지 않
|
|||||||
### 3.2 picker baseline과 enhancement
|
### 3.2 picker baseline과 enhancement
|
||||||
|
|
||||||
- 접근 가능한 `<input type="file">`가 모든 browser의 canonical baseline이다.
|
- 접근 가능한 `<input type="file">`가 모든 browser의 canonical baseline이다.
|
||||||
|
baseline adapter는 `input.click()`으로 이 경로를 활성화한다. `showPicker()`
|
||||||
|
존재 여부는 portable baseline capability의 판정 기준으로 사용하지 않는다.
|
||||||
- `showOpenFilePicker()`와 `showSaveFilePicker()`는 runtime method별 feature
|
- `showOpenFilePicker()`와 `showSaveFilePicker()`는 runtime method별 feature
|
||||||
detection을 거친 progressive enhancement다. UA sniffing을 사용하지 않는다.
|
detection을 거친 progressive enhancement다. UA sniffing을 사용하지 않는다.
|
||||||
- picker 호출은 click/keyboard handler의 첫 browser action이어야 한다. 그 전에
|
- picker 호출은 click/keyboard handler의 첫 browser action이어야 한다. 그 전에
|
||||||
@@ -987,7 +989,7 @@ recipe의 공통 code는 UI/telemetry에 raw DOMException을 노출하지 않기
|
|||||||
| `PERMISSION_DENIED` | picker/save permission | baseline/manual fallback |
|
| `PERMISSION_DENIED` | picker/save permission | baseline/manual fallback |
|
||||||
| `LIMIT_EXCEEDED` | count/bytes/page/buffer budget | 입력 축소 |
|
| `LIMIT_EXCEEDED` | count/bytes/page/buffer budget | 입력 축소 |
|
||||||
| `POLICY_REJECTED` | type/cache/data policy | 저장·전송 금지 |
|
| `POLICY_REJECTED` | type/cache/data policy | 저장·전송 금지 |
|
||||||
| `BLOCKED` | IndexedDB older context | 다른 탭 close/retry UI |
|
| `BLOCKED` | IndexedDB older context가 live open/upgrade를 막는 중 | 다른 탭 close/retry UI |
|
||||||
| `CONFLICT` | revision/generation/idempotency conflict | authoritative re-read |
|
| `CONFLICT` | revision/generation/idempotency conflict | authoritative re-read |
|
||||||
| `MIGRATION_FAILED` | schema/data migration | read-only/online-only |
|
| `MIGRATION_FAILED` | schema/data migration | read-only/online-only |
|
||||||
| `QUOTA_EXCEEDED` | actual write failure | rollback, reconstructable GC, bounded retry |
|
| `QUOTA_EXCEEDED` | actual write failure | rollback, reconstructable GC, bounded retry |
|
||||||
@@ -999,6 +1001,12 @@ recipe의 공통 code는 UI/telemetry에 raw DOMException을 노출하지 않기
|
|||||||
| `UNAVAILABLE` | browser/worker/storage temporarily unavailable | documented fallback |
|
| `UNAVAILABLE` | browser/worker/storage temporarily unavailable | documented fallback |
|
||||||
| `UNSUPPORTED` | capability absence | baseline/online-only |
|
| `UNSUPPORTED` | capability absence | baseline/online-only |
|
||||||
|
|
||||||
|
`IndexedDbConnectionStatus.BLOCKED`는 대기 중인 live attempt의 observable
|
||||||
|
상태다. blocked deadline이 끝나 `open()`이 `BLOCKED` failure로 settle되면
|
||||||
|
그 attempt는 더 이상 열리는 중이 아니므로 status는
|
||||||
|
`CLOSED / NOT_OPENED`로 전이한다. 늦게 성공한 native connection은 즉시 닫고
|
||||||
|
settled status를 되살리지 않는다.
|
||||||
|
|
||||||
user dismissal은 failure가 아니라 outcome이다. browser DOMException name은
|
user dismissal은 failure가 아니라 outcome이다. browser DOMException name은
|
||||||
adapter에서 이 vocabulary로 mapping하고 raw message/stack은 local bounded
|
adapter에서 이 vocabulary로 mapping하고 raw message/stack은 local bounded
|
||||||
diagnostic에도 기본 저장하지 않는다.
|
diagnostic에도 기본 저장하지 않는다.
|
||||||
@@ -1085,18 +1093,17 @@ Playwright Chromium, Firefox, WebKit에서 실제 secure-origin API를 검사한
|
|||||||
`test:browser-capabilities`의 JUnit을
|
`test:browser-capabilities`의 JUnit을
|
||||||
`verify:browser-capability-evidence`가 읽어 세 engine의 testcase 집합 동일성,
|
`verify:browser-capability-evidence`가 읽어 세 engine의 testcase 집합 동일성,
|
||||||
양수 실행 수, zero failure/error/skipped와 skipped/failure node 부재를 강제한다.
|
양수 실행 수, zero failure/error/skipped와 skipped/failure node 부재를 강제한다.
|
||||||
현재 checkout의 source suite는 engine마다 정확히 같은 14개 case(File 2,
|
browser capability promotion은 source suite에서 관찰된 동일 testcase set을
|
||||||
IndexedDB 4, OPFS/Cache/StorageManager 각 1, cross-context invalidation 2,
|
Chromium/Firefox/WebKit에서 모두 실행하는 규칙으로 관리한다. case 수를 문서
|
||||||
presigned streaming download/multipart upload/Image CDN 각 1)를 정의한다.
|
상수로 복제하지 않고 `verify:browser-capability-evidence`가 Chromium 결과를
|
||||||
promotion artifact는 Chromium/Firefox/WebKit의 14개씩, 총 42개가 모두
|
baseline set으로 계산해 Firefox/WebKit의 set 동일성, 양수 실행 수,
|
||||||
실행되어야 한다. 이 host의
|
failure/error/skipped 0과 failure/skipped node 부재를 검증한다.
|
||||||
WebKit은 필수 native libraries(예:
|
|
||||||
`libbacktrace.so.0`, `libevent-2.1.so.7`, `libjxl.so.0.8`,
|
capability absence도 skip 사유가 아니다. 예를 들어 어떤 engine에서
|
||||||
`libavif.so.16`과 WPE 계열) 부재로 실행되지 않았고 현재 보존 artifact도
|
`navigator.storage`가 없다면 동일 testcase 안에서 adapter의
|
||||||
Chromium/Firefox 14개씩 총 28개만 통과한 상태다. 따라서 promotion evidence를
|
`UNSUPPORTED / STORAGE_ESTIMATE / ONLINE_ONLY` 결과를 browser truth로
|
||||||
충족하지 않으며 verifier가 실패하는 것이 정상이다. `INSTALLED` 전에는 필요한
|
검증한다. 세 engine을 실행할 수 없는 host의 artifact는 promotion evidence로
|
||||||
system dependency가 있는 CI/device에서 세 engine 전체 evidence를 새로 생성해야
|
사용하지 않는다.
|
||||||
한다.
|
|
||||||
|
|
||||||
- native input keyboard/focus/same-file reselection/multiple/dismissal
|
- native input keyboard/focus/same-file reselection/multiple/dismissal
|
||||||
- Chromium conditional picker/save enhancement와 다른 engine fallback
|
- Chromium conditional picker/save enhancement와 다른 engine fallback
|
||||||
|
|||||||
@@ -48,18 +48,39 @@ its business requirement.
|
|||||||
|
|
||||||
## Scenario B — IndexedDB local draft
|
## Scenario B — IndexedDB local draft
|
||||||
|
|
||||||
An executable consumer probe lives at:
|
The original executable consumer probe remains at:
|
||||||
|
|
||||||
`tests/contract/consumer-experience/indexeddb-local-draft.test.ts`
|
`tests/contract/consumer-experience/indexeddb-local-draft.test.ts`
|
||||||
|
|
||||||
with the feature-owned fixture:
|
with its isolated fixture:
|
||||||
|
|
||||||
`tests/contract/consumer-experience/fixtures/local-draft-feature.ts`
|
`tests/contract/consumer-experience/fixtures/local-draft-feature.ts`
|
||||||
|
|
||||||
The fixture models a small local-draft feature with save/find/remove and
|
The architecture is now also exercised by a real vertical slice under:
|
||||||
optimistic revision checking.
|
|
||||||
|
|
||||||
Measured result:
|
`src/features/local-draft-feature`
|
||||||
|
|
||||||
|
It owns the Local Draft domain/application API and binds an
|
||||||
|
`IndexedDbRepositoryPort<LocalDraft, never>` in its feature adapter. The
|
||||||
|
cross-capability composition contract lives at:
|
||||||
|
|
||||||
|
`tests/contract/reusable-capability/feature-adapter-composition.test.ts`
|
||||||
|
|
||||||
|
That test composes one HTTP-only contribution and the IndexedDB-only Local Draft
|
||||||
|
contribution through the same generic catalog path.
|
||||||
|
|
||||||
|
The browser-level composition proof lives at:
|
||||||
|
|
||||||
|
`tests/browser-capabilities/local-draft-composition.spec.ts`
|
||||||
|
|
||||||
|
It runs against native browser IndexedDB. The test creates the platform
|
||||||
|
`createIndexedDbRuntime`, exposes that runtime through the typed
|
||||||
|
`createIndexedDbRepositoryProvider`, composes
|
||||||
|
`LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION`, then executes Local Draft
|
||||||
|
save/find/remove through the feature API. The Local Draft feature still does
|
||||||
|
not import the IndexedDB runtime or native browser API.
|
||||||
|
|
||||||
|
Measured probe result:
|
||||||
|
|
||||||
| Metric | Result |
|
| Metric | Result |
|
||||||
| --- | ---: |
|
| --- | ---: |
|
||||||
@@ -81,8 +102,11 @@ The contract test rejects feature source that reaches for
|
|||||||
|
|
||||||
## What this does and does not prove
|
## What this does and does not prove
|
||||||
|
|
||||||
The probe confirms that **feature business code does not need native IndexedDB
|
The contract probe confirms that **feature business code does not need native
|
||||||
knowledge** once an `IndexedDbRepositoryPort` has been composed.
|
IndexedDB knowledge** once an `IndexedDbRepositoryPort` has been composed.
|
||||||
|
The browser-capability proof additionally confirms that the same feature
|
||||||
|
contribution works when that port is backed by the repository's real
|
||||||
|
`createIndexedDbRuntime` and native IndexedDB implementation.
|
||||||
|
|
||||||
It does not prove that composition of `createIndexedDbRuntime` is cheap.
|
It does not prove that composition of `createIndexedDbRuntime` is cheap.
|
||||||
That constructor still owns substantial infrastructure policy:
|
That constructor still owns substantial infrastructure policy:
|
||||||
@@ -96,10 +120,15 @@ That constructor still owns substantial infrastructure policy:
|
|||||||
- durability, scheduling and observation.
|
- durability, scheduling and observation.
|
||||||
|
|
||||||
That complexity belongs at the composition/platform boundary, not in the
|
That complexity belongs at the composition/platform boundary, not in the
|
||||||
feature. A new `create...Repository<T>` convenience factory should be added
|
feature. The Local Draft vertical slice is now the second concrete consumer, and
|
||||||
only when a second real product consumer demonstrates which subset is stable
|
it confirms that the stable seam is the typed repository provider plus a
|
||||||
enough to become a reusable profile. Creating one now would guess at policy and
|
feature-owned repository identity. It does **not** show that dataset scope,
|
||||||
risk producing a universal storage abstraction.
|
retention, migration, codec or lifecycle-authority configuration can be safely
|
||||||
|
collapsed into one universal `create...Repository<T>` factory.
|
||||||
|
|
||||||
|
A convenience profile should therefore be introduced only after another
|
||||||
|
IndexedDB-backed product feature repeats the same infrastructure policy, not
|
||||||
|
merely because two features consume the same repository port.
|
||||||
|
|
||||||
## Consumer-quality review checklist
|
## Consumer-quality review checklist
|
||||||
|
|
||||||
|
|||||||
@@ -189,17 +189,18 @@ chunk별 `CapabilityResult<Uint8Array>`를 반환한다. backend upload example
|
|||||||
실제 upload feature는 이 예시를 그대로 import하지 않고 purpose와 backend
|
실제 upload feature는 이 예시를 그대로 import하지 않고 purpose와 backend
|
||||||
protocol에 맞게 contract를 더 좁힌다.
|
protocol에 맞게 contract를 더 좁힌다.
|
||||||
|
|
||||||
현재 checkout의 browser source suite는 engine마다 같은 14개 case(File 2,
|
browser capability promotion은 source suite가 정의한 동일 testcase set을
|
||||||
IndexedDB 4, OPFS/Cache/StorageManager 각 1, cross-context invalidation 2,
|
Chromium/Firefox/WebKit에서 모두 실행해야 한다. 구체적인 case 개수는 이 결정
|
||||||
presigned streaming download/multipart upload/Image CDN 각 1)를 정의한다.
|
문서에 고정하지 않는다. `verify:browser-capability-evidence`가 Chromium
|
||||||
promotion artifact는 Chromium/Firefox/WebKit 각각 14개, 총 42개를 모두
|
artifact에서 baseline set을 계산하고 Firefox/WebKit과의 set 동일성 및
|
||||||
실행해야 한다. WebKit은 현재
|
failure/error/skipped 0을 기계적으로 검증한다.
|
||||||
host의 필수 native libraries(예:
|
|
||||||
`libbacktrace.so.0`, `libevent-2.1.so.7`, `libjxl.so.0.8`,
|
engine별 native capability 차이는 testcase를 skip하는 이유가 아니다. capability가
|
||||||
`libavif.so.16`과 WPE 계열) 부재로 실행되지 않았다. 보존 artifact는
|
없으면 adapter의 명시적 unsupported/degraded result를 같은 testcase에서
|
||||||
Chromium/Firefox 14개씩 총 28개만 통과했으므로
|
검증한다. baseline `<input type="file">` activation은 portable한
|
||||||
`verify:browser-capability-evidence`가 실패하는 것이 정상이다. 세 engine
|
`input.click()` 경로를 사용하고, `showOpenFilePicker()` 같은 API는 별도
|
||||||
evidence가 완성되기 전에는 product 상태를 `INSTALLED`로 올리지 않는다.
|
progressive enhancement로 유지한다. 세 engine evidence가 완성되기 전에는
|
||||||
|
product 상태를 `INSTALLED`로 올리지 않는다.
|
||||||
|
|
||||||
## 선택 이후 필요한 구현
|
## 선택 이후 필요한 구현
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,39 @@ Central installed catalogs are aggregation points only:
|
|||||||
Feature-specific composition belongs under the feature itself. Central
|
Feature-specific composition belongs under the feature itself. Central
|
||||||
catalogs must not grow feature-specific branching logic.
|
catalogs must not grow feature-specific branching logic.
|
||||||
|
|
||||||
|
Adapter contributions declare the platform capabilities they consume through
|
||||||
|
`needs`. The generic contribution seam in
|
||||||
|
`src/features/feature-adapter-contribution.ts` derives the context from that
|
||||||
|
list, so an HTTP-only feature cannot reach IndexedDB and an IndexedDB-only
|
||||||
|
feature does not receive the HTTP executor. It also binds
|
||||||
|
`featureId -> ApplicationFeatureInputs[featureId]` at the contribution
|
||||||
|
definition site instead of recovering that relationship with a final catalog
|
||||||
|
cast.
|
||||||
|
|
||||||
|
A contribution becomes composable only through
|
||||||
|
`defineFeatureAdapterContribution()`. That factory adds the private
|
||||||
|
contribution brand required by `composeFeatureAdapterInputs()`; a raw object
|
||||||
|
with the same visible fields is rejected by TypeScript and checked again at
|
||||||
|
runtime. The negative type fixture
|
||||||
|
`invalid-direct-feature-composition.ts` is part of FE-GATE-003 so this
|
||||||
|
authority cannot be bypassed by calling the composer directly.
|
||||||
|
|
||||||
|
For IndexedDB-backed features, `createIndexedDbRepositoryProvider()` is the
|
||||||
|
composition helper that maps feature-owned repository identities to typed
|
||||||
|
`IndexedDbRepositoryPort` instances. It does not create a universal storage
|
||||||
|
repository or move dataset/migration/lifecycle policy into the feature.
|
||||||
|
|
||||||
|
The repository now has two executable contribution shapes:
|
||||||
|
|
||||||
|
- Reference feature: `needs: ["http"]`
|
||||||
|
- Local Draft feature: `needs: ["indexedDb"]`
|
||||||
|
|
||||||
|
Local Draft is a compiled architecture consumer used to prove the second
|
||||||
|
capability shape; it is not added to the default product manifest. Its
|
||||||
|
browser-capability test composes the feature over the real
|
||||||
|
`createIndexedDbRuntime` and native IndexedDB, proving that this seam is not
|
||||||
|
limited to an in-memory contract fixture.
|
||||||
|
|
||||||
## Presentation consumer surface
|
## Presentation consumer surface
|
||||||
|
|
||||||
`ApplicationProvider` remains the composition root for presentation, but new
|
`ApplicationProvider` remains the composition root for presentation, but new
|
||||||
|
|||||||
@@ -337,7 +337,14 @@ Run on the landed tree. Only what actually passed is claimed as passing.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `tests/unit/ci-workflow-generation.test.ts` | 82 failed / 325 passed | Identical on the pre-change baseline (`git stash` comparison). The subprocess gates it spawns cannot run in this sandbox. |
|
| `tests/unit/ci-workflow-generation.test.ts` | 82 failed / 325 passed | Identical on the pre-change baseline (`git stash` comparison). The subprocess gates it spawns cannot run in this sandbox. |
|
||||||
| `tests/unit/ci-artifact-contract.test.ts` | fails | Unchanged pre-existing sandbox, cgroup and `/tmp` permission behaviour. |
|
| `tests/unit/ci-artifact-contract.test.ts` | fails | Unchanged pre-existing sandbox, cgroup and `/tmp` permission behaviour. |
|
||||||
| `tests/unit/security-followup.test.ts`, `tests/unit/provider-guardian-transaction.test.ts`, `tests/unit/risk-coverage.test.ts` | flaky under full-suite load | All three pass in a fresh process (78 passed together). They spawn and reap process groups, so their timing assertions are load sensitive. |
|
| `tests/unit/security-followup.test.ts`, `tests/unit/provider-guardian-transaction.test.ts`, `tests/unit/risk-coverage.test.ts` | flaky under full-suite load | Historical baseline result. All three passed in a fresh process (78 passed together). |
|
||||||
|
|
||||||
|
> Current classification update (2026-09-18): the provider guardian transaction
|
||||||
|
> suite moved to `tests/integration/provider-guardian-transaction.test.ts`.
|
||||||
|
> It spawns child processes and exercises filesystem/IPC/process lifecycle, so it
|
||||||
|
> is not part of the pure unit pool. Its READY/PUBLISHED wait uses a dedicated
|
||||||
|
> test watchdog rather than treating a 1-second scheduler delay as a production
|
||||||
|
> protocol deadline.
|
||||||
|
|
||||||
### Destructive fixture hazard — fixed
|
### Destructive fixture hazard — fixed
|
||||||
|
|
||||||
@@ -375,7 +382,7 @@ files above plus the two flaky-under-load ones:
|
|||||||
| `tests/unit/ci-workflow-generation.test.ts` | 82 | Identical on the pre-change baseline (`git stash` comparison). Its subprocess gates cannot run in this sandbox. |
|
| `tests/unit/ci-workflow-generation.test.ts` | 82 | Identical on the pre-change baseline (`git stash` comparison). Its subprocess gates cannot run in this sandbox. |
|
||||||
| `tests/unit/ci-artifact-contract.test.ts` | 19 | Unchanged pre-existing sandbox, cgroup and `/tmp` permission behaviour. |
|
| `tests/unit/ci-artifact-contract.test.ts` | 19 | Unchanged pre-existing sandbox, cgroup and `/tmp` permission behaviour. |
|
||||||
| `tests/unit/security-followup.test.ts` | 2 | Passes in isolation. |
|
| `tests/unit/security-followup.test.ts` | 2 | Passes in isolation. |
|
||||||
| `tests/unit/provider-guardian-transaction.test.ts` | 1 | Passes in isolation. |
|
| `tests/unit/provider-guardian-transaction.test.ts` (historical path; now `tests/integration/provider-guardian-transaction.test.ts`) | 1 | Historical baseline: passed in isolation; current suite is classified as process integration. |
|
||||||
|
|
||||||
1619 passed / 1723 total, and `tests/unit/removal-fixture.test.ts`,
|
1619 passed / 1723 total, and `tests/unit/removal-fixture.test.ts`,
|
||||||
`tests/unit/supply-chain.test.ts` and
|
`tests/unit/supply-chain.test.ts` and
|
||||||
|
|||||||
@@ -148,6 +148,9 @@ read-only나 online-only가 사용자 작성 내용을 잃게 한다면 먼저 e
|
|||||||
### 신호
|
### 신호
|
||||||
|
|
||||||
- `BLOCKED`, `UPGRADE_BLOCKED` 또는 blocked duration bucket 증가
|
- `BLOCKED`, `UPGRADE_BLOCKED` 또는 blocked duration bucket 증가
|
||||||
|
- live attempt가 기다리는 동안 status는 `BLOCKED`
|
||||||
|
- blocked deadline settle 뒤에는 open result가 `BLOCKED` failure여도
|
||||||
|
runtime status는 `CLOSED / NOT_OPENED`
|
||||||
- `versionchange` 뒤 connection이 남음
|
- `versionchange` 뒤 connection이 남음
|
||||||
- repeated reload/update loop
|
- repeated reload/update loop
|
||||||
- open/maintenance의 `POLICY_REJECTED`: immutable dataset binding missing/mismatch
|
- open/maintenance의 `POLICY_REJECTED`: immutable dataset binding missing/mismatch
|
||||||
@@ -500,16 +503,13 @@ browser engine/version, fixture ID, fault phase, bounded counts/buckets와 PASS/
|
|||||||
|
|
||||||
promotion 직전에는 다음 repository evidence도 함께 보존한다.
|
promotion 직전에는 다음 repository evidence도 함께 보존한다.
|
||||||
|
|
||||||
- `test:browser-capabilities`가 만든 JUnit에서 Chromium, Firefox, WebKit이 동일
|
- `test:browser-capabilities`가 만든 JUnit에서 Chromium, Firefox, WebKit이
|
||||||
14개 testcase set(File 2, IndexedDB 4, OPFS/Cache/StorageManager 각 1,
|
source suite의 동일 testcase set을 실제 실행하고 failure/error/skipped가 모두
|
||||||
cross-context invalidation 2, presigned streaming download/multipart
|
0이어야 한다. 구체적인 case 수는 이 runbook에 복제하지 않는다.
|
||||||
upload/Image CDN 각 1)을 실제 실행해 총 42개이며 failure/error/skipped가 모두
|
`verify:browser-capability-evidence`가 Chromium을 baseline으로 testcase set과
|
||||||
0이어야 한다.
|
engine 집합을 동적으로 검증한다. native capability가 없는 engine도 testcase를
|
||||||
`verify:browser-capability-evidence`가 engine 집합과 testcase 동일성을
|
skip하지 않고 adapter의 명시적 `UNSUPPORTED`/degraded 결과를 검증해야 한다.
|
||||||
기계적으로 검증한다. 현재 artifact는 Chromium/Firefox 14개씩 총 28개가
|
세 engine 중 하나라도 실행되지 않은 artifact는 promotion 가능 상태가
|
||||||
통과했지만 WebKit 실행에 필요한 native libraries(예:
|
|
||||||
`libbacktrace.so.0`, `libevent-2.1.so.7`, `libjxl.so.0.8`,
|
|
||||||
`libavif.so.16`과 WPE 계열)가 이 host에 없으므로 아직 promotion 가능 상태가
|
|
||||||
아니다.
|
아니다.
|
||||||
- `artifacts/quality/vite-module-inventory.json`에서 optional runtime source root가
|
- `artifacts/quality/vite-module-inventory.json`에서 optional runtime source root가
|
||||||
production chunk에 없음을 `check:optional-recipes`로 검증한다.
|
production chunk에 없음을 `check:optional-recipes`로 검증한다.
|
||||||
|
|||||||
@@ -105,6 +105,24 @@ component suite로 분리되어 있다.
|
|||||||
실제 QueryClient 위에서 검증한다. HTTP 자동 retry가 소유자이므로 이 adapter의
|
실제 QueryClient 위에서 검증한다. HTTP 자동 retry가 소유자이므로 이 adapter의
|
||||||
query/mutation vendor retry는 꺼져 있다.
|
query/mutation vendor retry는 꺼져 있다.
|
||||||
|
|
||||||
|
같은 기준을 대형 unit suite에도 적용한다. 줄 수를 기준으로 자르지 않고 실패가
|
||||||
|
가리켜야 하는 behavior owner를 기준으로 분리한다.
|
||||||
|
|
||||||
|
- `public-response-cache.test.ts`: 일반 stage/activate/lookup/cache contract
|
||||||
|
- `public-response-cache-repair.test.ts`: active release repair의 failure atomicity
|
||||||
|
- `resumable-upload-runtime.test.ts`: upload/reconcile/control-plane 흐름
|
||||||
|
- `resumable-upload-runtime-teardown.test.ts`: bounded drain/raw provider teardown
|
||||||
|
- `security-followup.test.ts`: archived local evidence
|
||||||
|
- `security-provider-evidence.test.ts`: provider signature/supervision/process lifecycle
|
||||||
|
- `security-promotion-staging.test.ts`: private staging seal/replay/CLI identity
|
||||||
|
- `tests/integration/provider-guardian-transaction.test.ts`: child process,
|
||||||
|
filesystem, IPC frame, READY/PUBLISHED handshake와 process lifecycle. pure unit
|
||||||
|
pool에 두지 않으며 handshake budget은 protocol timeout이 아닌 test watchdog이다.
|
||||||
|
- 각 `*-fixture.ts`: 해당 owner들 사이에서만 공유하는 deterministic test fixture
|
||||||
|
|
||||||
|
이 분리는 production owner와 test failure surface를 맞추기 위한 것이며, 단순
|
||||||
|
LOC 감축 목적이 아니다.
|
||||||
|
|
||||||
#### Form과 route 위험
|
#### Form과 route 위험
|
||||||
|
|
||||||
form component/reference feature test가 error summary, 첫 오류 focus, Zod
|
form component/reference feature test가 error summary, 첫 오류 focus, Zod
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Each gate is blocking in its declared scope. Failures are not downgraded with
|
|||||||
| unit | `pnpm test:unit` | Node-only domain/application/pure policy/runtime units; no systemd/bwrap/cgroup prerequisite | `artifacts/tests/unit.xml` |
|
| unit | `pnpm test:unit` | Node-only domain/application/pure policy/runtime units; no systemd/bwrap/cgroup prerequisite | `artifacts/tests/unit.xml` |
|
||||||
| capability contract | `pnpm test:contract` | reusable capability consumer contracts | `artifacts/tests/contract.xml` |
|
| capability contract | `pnpm test:contract` | reusable capability consumer contracts | `artifacts/tests/contract.xml` |
|
||||||
| component | `pnpm test:component` | React/hook/UI behavior | `artifacts/tests/component.xml` |
|
| component | `pnpm test:component` | React/hook/UI behavior | `artifacts/tests/component.xml` |
|
||||||
| integration | `pnpm test:integration` | HTTP/MSW, IndexedDB and composed browser-runtime boundaries | `artifacts/tests/integration.xml` |
|
| integration | `pnpm test:integration` | HTTP/MSW, IndexedDB, composed browser-runtime boundaries, child-process/filesystem/IPC integration | `artifacts/tests/integration.xml` |
|
||||||
| system / CI runner | `pnpm test:system` | compatible Linux host with systemd, bubblewrap, cgroup v2 and CI-provider process controls | `artifacts/tests/system.xml` |
|
| system / CI runner | `pnpm test:system` | compatible Linux host with systemd, bubblewrap, cgroup v2 and CI-provider process controls | `artifacts/tests/system.xml` |
|
||||||
| end-to-end | `pnpm test:e2e` | pinned browser engines | `artifacts/tests/e2e/` |
|
| end-to-end | `pnpm test:e2e` | pinned browser engines | `artifacts/tests/e2e/` |
|
||||||
| accessibility | `pnpm test:a11y` | pinned browser engines | `artifacts/tests/a11y.json` |
|
| accessibility | `pnpm test:a11y` | pinned browser engines | `artifacts/tests/a11y.json` |
|
||||||
@@ -64,7 +64,11 @@ CI / release assurance:
|
|||||||
|
|
||||||
A host-level process/sandbox test must not be placed in `tests/unit` merely
|
A host-level process/sandbox test must not be placed in `tests/unit` merely
|
||||||
because it uses Vitest. The classification follows the system boundary and
|
because it uses Vitest. The classification follows the system boundary and
|
||||||
prerequisites, not the test framework.
|
prerequisites, not the test framework. Process-heavy tests that spawn child
|
||||||
|
processes but do not require privileged host facilities belong in
|
||||||
|
`tests/integration`; for example the provider guardian transaction protocol
|
||||||
|
lives at `tests/integration/provider-guardian-transaction.test.ts`. Its
|
||||||
|
handshake timeout is a test watchdog, not a production protocol deadline.
|
||||||
|
|
||||||
End-to-end and automated accessibility scenarios run on the pinned Chromium,
|
End-to-end and automated accessibility scenarios run on the pinned Chromium,
|
||||||
Firefox, and WebKit engines. The responsive contract explicitly exercises
|
Firefox, and WebKit engines. The responsive contract explicitly exercises
|
||||||
|
|||||||
+24
-17
@@ -30,22 +30,22 @@
|
|||||||
"check:types:node": "tsc --project tsconfig.node.json",
|
"check:types:node": "tsc --project tsconfig.node.json",
|
||||||
"check:types:test": "tsc --project tsconfig.test.json",
|
"check:types:test": "tsc --project tsconfig.test.json",
|
||||||
"check:types:recipes": "node scripts/check-optional-recipe-types.ts",
|
"check:types:recipes": "node scripts/check-optional-recipe-types.ts",
|
||||||
"check:types:fixture": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext tests/fixtures/typecheck/invalid-port-call.ts",
|
"check:types:fixture": "tsc -p tests/fixtures/typecheck/tsconfig.port-call.json",
|
||||||
"check:types:fixture:ts-port": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-port-implementation.ts",
|
"check:types:fixture:ts-port": "tsc -p tests/fixtures/typecheck/tsconfig.port-implementation.json",
|
||||||
"check:types:fixture:ts-result": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-result-narrowing.ts",
|
"check:types:fixture:ts-result": "tsc -p tests/fixtures/typecheck/tsconfig.result-narrowing.json",
|
||||||
"check:types:fixture:application-output": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-output.ts",
|
"check:types:fixture:application-output": "tsc -p tests/fixtures/typecheck/tsconfig.application-output.json",
|
||||||
"check:types:fixture:application-input": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-input.ts",
|
"check:types:fixture:application-input": "tsc -p tests/fixtures/typecheck/tsconfig.application-input.json",
|
||||||
"check:types:fixture:feature-input": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-feature-input.ts",
|
"check:types:fixture:feature-input": "tsc -p tests/fixtures/typecheck/tsconfig.feature-input.json",
|
||||||
"check:types:fixture:failure-kind": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-failure-kind.ts",
|
"check:types:fixture:failure-kind": "tsc -p tests/fixtures/typecheck/tsconfig.failure-kind.json",
|
||||||
"check:types:fixture:reference-operation": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-reference-operation.ts",
|
"check:types:fixture:reference-operation": "tsc -p tests/fixtures/typecheck/tsconfig.reference-operation.json",
|
||||||
"check:types:fixture:async-overlay": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-async-overlay.ts",
|
"check:types:fixture:async-overlay": "tsc -p tests/fixtures/typecheck/tsconfig.async-overlay.json",
|
||||||
"check:types:fixture:route-runtime": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-route-runtime.ts",
|
"check:types:fixture:route-runtime": "tsc -p tests/fixtures/typecheck/tsconfig.route-runtime.json",
|
||||||
"check:types:fixture:page-action": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-page-action.tsx",
|
"check:types:fixture:page-action": "tsc -p tests/fixtures/typecheck/tsconfig.page-action.json",
|
||||||
"check:types:fixture:icon-button": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-icon-button.tsx",
|
"check:types:fixture:icon-button": "tsc -p tests/fixtures/typecheck/tsconfig.icon-button.json",
|
||||||
"check:types:fixture:i18n-key": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-key.ts",
|
"check:types:fixture:i18n-key": "tsc -p tests/fixtures/typecheck/tsconfig.message-key.json",
|
||||||
"check:types:fixture:i18n-params": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-params.ts",
|
"check:types:fixture:i18n-params": "tsc -p tests/fixtures/typecheck/tsconfig.message-params.json",
|
||||||
"check:types:fixture:diagnostics": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-diagnostics-port.ts",
|
"check:types:fixture:diagnostics": "tsc -p tests/fixtures/typecheck/tsconfig.diagnostics-port.json",
|
||||||
"check:types:fixture:image-resolve-signal": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-image-cdn-resolve-signal.ts",
|
"check:types:fixture:image-resolve-signal": "tsc -p tests/fixtures/typecheck/tsconfig.image-cdn-resolve-signal.json",
|
||||||
"test:runtime-schema": "node scripts/run-vitest.ts run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests",
|
"test:runtime-schema": "node scripts/run-vitest.ts run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests",
|
||||||
"test:unit": "node scripts/run-vitest.ts run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml",
|
"test:unit": "node scripts/run-vitest.ts run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml",
|
||||||
"test:contract": "node scripts/run-vitest.ts run tests/contract --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/contract.xml",
|
"test:contract": "node scripts/run-vitest.ts run tests/contract --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/contract.xml",
|
||||||
@@ -124,7 +124,14 @@
|
|||||||
"build:app-only": "vite build && node scripts/generate-build-manifest.ts",
|
"build:app-only": "vite build && node scripts/generate-build-manifest.ts",
|
||||||
"generate:contract-set": "node scripts/generate-contract-set.ts",
|
"generate:contract-set": "node scripts/generate-contract-set.ts",
|
||||||
"check:types:web-worker": "tsc --project tsconfig.web-worker.json",
|
"check:types:web-worker": "tsc --project tsconfig.web-worker.json",
|
||||||
"check:types:service-worker": "tsc --project tsconfig.service-worker.json"
|
"check:types:service-worker": "tsc --project tsconfig.service-worker.json",
|
||||||
|
"check:types:fixture:http-operation-input": "tsc -p tests/fixtures/typecheck/tsconfig.http-operation-input.json",
|
||||||
|
"check:types:fixture:http-wire-mapper": "tsc -p tests/fixtures/typecheck/tsconfig.http-wire-mapper.json",
|
||||||
|
"check:types:fixture:http-operation-id": "tsc -p tests/fixtures/typecheck/tsconfig.http-operation-id.json",
|
||||||
|
"check:types:fixture:http-route-id": "tsc -p tests/fixtures/typecheck/tsconfig.http-route-id.json",
|
||||||
|
"check:types:fixture:feature-contribution-input": "tsc -p tests/fixtures/typecheck/tsconfig.feature-contribution-input.json",
|
||||||
|
"check:types:fixture:feature-capability-selection": "tsc -p tests/fixtures/typecheck/tsconfig.feature-capability-selection.json",
|
||||||
|
"check:types:fixture:direct-feature-composition": "tsc -p tests/fixtures/typecheck/tsconfig.direct-feature-composition.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "5.101.4",
|
"@tanstack/react-query": "5.101.4",
|
||||||
|
|||||||
@@ -443,7 +443,7 @@ export type LoadCiGateContractOptions = Readonly<{
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
const CANONICAL_GATE_SHAPE_SHA256 =
|
const CANONICAL_GATE_SHAPE_SHA256 =
|
||||||
"372959f50b9c5a228bdc85dce33ca7968c72414ce3b6d5534c9e6eaf554f6ce4";
|
"43dc4ed1cdaf21a674bd069d42eb2c32276afd8f763d72e52c9f7c3adccbeb58";
|
||||||
|
|
||||||
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
|
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
|
||||||
const normalized = gates.map(
|
const normalized = gates.map(
|
||||||
@@ -477,9 +477,9 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
|
|||||||
if (contract.gates.length !== 27) {
|
if (contract.gates.length !== 27) {
|
||||||
failures.push(`gate authority baseline must contain exactly 27 gates; received ${contract.gates.length}`);
|
failures.push(`gate authority baseline must contain exactly 27 gates; received ${contract.gates.length}`);
|
||||||
}
|
}
|
||||||
if (contract.commands.length !== 84 || commandReferenceCount !== 96) {
|
if (contract.commands.length !== 91 || commandReferenceCount !== 103) {
|
||||||
failures.push(
|
failures.push(
|
||||||
`command authority baseline must contain exactly 84 definitions and 96 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`,
|
`command authority baseline must contain exactly 91 definitions and 103 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (contract.artifacts.length !== 109) {
|
if (contract.artifacts.length !== 109) {
|
||||||
|
|||||||
@@ -42,18 +42,39 @@ const featureOwnedPaths = [
|
|||||||
featureSource,
|
featureSource,
|
||||||
featureTests,
|
featureTests,
|
||||||
"tests/integration/http-scenario-catalog.test.ts",
|
"tests/integration/http-scenario-catalog.test.ts",
|
||||||
|
"tests/component/product-feature-switch.test.tsx",
|
||||||
"tests/e2e/reference-form.spec.ts",
|
"tests/e2e/reference-form.spec.ts",
|
||||||
"tests/e2e/reference-route.spec.ts",
|
"tests/e2e/reference-route.spec.ts",
|
||||||
"tests/mocks",
|
"tests/mocks",
|
||||||
"tests/fixtures/typecheck/invalid-feature-input.ts",
|
"tests/fixtures/typecheck/invalid-feature-input.ts",
|
||||||
"tests/fixtures/typecheck/invalid-reference-operation.ts",
|
"tests/fixtures/typecheck/invalid-reference-operation.ts",
|
||||||
|
"tests/fixtures/typecheck/invalid-http-operation-input.ts",
|
||||||
|
"tests/fixtures/typecheck/invalid-http-wire-mapper.ts",
|
||||||
|
"tests/fixtures/typecheck/invalid-http-operation-id.ts",
|
||||||
|
"tests/fixtures/typecheck/invalid-http-route-id.ts",
|
||||||
|
"tests/fixtures/typecheck/invalid-feature-contribution-input.ts",
|
||||||
|
"tests/fixtures/typecheck/invalid-feature-capability-selection.ts",
|
||||||
|
"tests/fixtures/typecheck/tsconfig.feature-input.json",
|
||||||
|
"tests/fixtures/typecheck/tsconfig.reference-operation.json",
|
||||||
|
"tests/fixtures/typecheck/tsconfig.http-operation-input.json",
|
||||||
|
"tests/fixtures/typecheck/tsconfig.http-wire-mapper.json",
|
||||||
|
"tests/fixtures/typecheck/tsconfig.http-operation-id.json",
|
||||||
|
"tests/fixtures/typecheck/tsconfig.http-route-id.json",
|
||||||
|
"tests/fixtures/typecheck/tsconfig.feature-contribution-input.json",
|
||||||
|
"tests/fixtures/typecheck/tsconfig.feature-capability-selection.json",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const emptyProductManifest = `export const COMPILED_PRODUCT_FEATURE_IDS: readonly string[] = Object.freeze([]);
|
||||||
|
export const INSTALLED_PRODUCT_FEATURES: readonly never[] = Object.freeze([]);
|
||||||
|
export const INSTALLED_PRODUCT_FEATURE_IDS: readonly string[] = Object.freeze([]);
|
||||||
|
`;
|
||||||
|
|
||||||
const emptyContracts = `import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.ts";
|
const emptyContracts = `import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.ts";
|
||||||
import { PLATFORM_ROUTE_REGISTRY, type RouteDefinition } from "../contracts/routes.ts";
|
import { PLATFORM_ROUTE_REGISTRY, type RouteDefinition } from "../contracts/routes.ts";
|
||||||
import { PLATFORM_SCHEMA_REGISTRY } from "../contracts/schema-registry.ts";
|
import { PLATFORM_SCHEMA_REGISTRY } from "../contracts/schema-registry.ts";
|
||||||
|
import { INSTALLED_PRODUCT_FEATURES } from "./installed-product-manifest.ts";
|
||||||
|
|
||||||
export const INSTALLED_FEATURE_CONTRACTS: readonly unknown[] = Object.freeze([]);
|
export const INSTALLED_FEATURE_CONTRACTS = INSTALLED_PRODUCT_FEATURES;
|
||||||
export const ROUTE_REGISTRY = PLATFORM_ROUTE_REGISTRY;
|
export const ROUTE_REGISTRY = PLATFORM_ROUTE_REGISTRY;
|
||||||
export const ROUTE_RUNTIME_CONTRACT = PLATFORM_ROUTE_RUNTIME_CONTRACT;
|
export const ROUTE_RUNTIME_CONTRACT = PLATFORM_ROUTE_RUNTIME_CONTRACT;
|
||||||
export const API_OPERATIONS = Object.freeze({});
|
export const API_OPERATIONS = Object.freeze({});
|
||||||
@@ -64,6 +85,7 @@ export const INVALIDATION_REGISTRY = Object.freeze({
|
|||||||
});
|
});
|
||||||
export const INVALIDATION_TOPIC_VERSIONS = Object.freeze([]);
|
export const INVALIDATION_TOPIC_VERSIONS = Object.freeze([]);
|
||||||
export const SCHEMA_REGISTRY = PLATFORM_SCHEMA_REGISTRY;
|
export const SCHEMA_REGISTRY = PLATFORM_SCHEMA_REGISTRY;
|
||||||
|
export const ROUTE_FEATURE_OWNER: Readonly<Record<string, string>> = Object.freeze({});
|
||||||
export const NAVIGATION_ROUTES = Object.freeze(
|
export const NAVIGATION_ROUTES = Object.freeze(
|
||||||
Object.values(ROUTE_REGISTRY)
|
Object.values(ROUTE_REGISTRY)
|
||||||
.filter((definition) => definition.navigationOrder !== null)
|
.filter((definition) => definition.navigationOrder !== null)
|
||||||
@@ -85,14 +107,39 @@ export function routePath(routeId: string): string {
|
|||||||
|
|
||||||
const emptyRuntimes = `import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.ts";
|
const emptyRuntimes = `import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.ts";
|
||||||
import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.tsx";
|
import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.tsx";
|
||||||
|
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
|
||||||
|
|
||||||
|
const COMPILED_FEATURE_RUNTIME_CONTRIBUTIONS: readonly Readonly<{
|
||||||
|
featureId: string;
|
||||||
|
}>[] = Object.freeze([]);
|
||||||
|
|
||||||
|
const INSTALLED_FEATURE_RUNTIME_CONTRIBUTIONS =
|
||||||
|
COMPILED_FEATURE_RUNTIME_CONTRIBUTIONS.filter((contribution) =>
|
||||||
|
INSTALLED_PRODUCT_FEATURE_IDS.includes(contribution.featureId),
|
||||||
|
);
|
||||||
|
|
||||||
|
void INSTALLED_FEATURE_RUNTIME_CONTRIBUTIONS;
|
||||||
|
|
||||||
export const ROUTE_CODECS = PLATFORM_ROUTE_CODECS;
|
export const ROUTE_CODECS = PLATFORM_ROUTE_CODECS;
|
||||||
export const ROUTE_RUNTIME = PLATFORM_ROUTE_RUNTIME;
|
export const ROUTE_RUNTIME = PLATFORM_ROUTE_RUNTIME;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const emptyAdapters = `export function createInstalledFeatureInputs(_context: unknown) {
|
const emptyAdapters = `import {
|
||||||
void _context;
|
composeFeatureAdapterInputs,
|
||||||
return Object.freeze({});
|
type InstalledFeatureInputs,
|
||||||
|
} from "./feature-adapter-contribution.ts";
|
||||||
|
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
|
||||||
|
|
||||||
|
const COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS = Object.freeze([] as const);
|
||||||
|
|
||||||
|
export function createInstalledFeatureInputs(
|
||||||
|
context: Readonly<Record<never, never>>,
|
||||||
|
): InstalledFeatureInputs {
|
||||||
|
return composeFeatureAdapterInputs(
|
||||||
|
COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS,
|
||||||
|
INSTALLED_PRODUCT_FEATURE_IDS,
|
||||||
|
context,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -101,9 +148,17 @@ const emptyContractContributions = `import {
|
|||||||
type InstalledContractContribution,
|
type InstalledContractContribution,
|
||||||
type InstalledContractPackageIdentity,
|
type InstalledContractPackageIdentity,
|
||||||
} from "../contracts/external-contract-runtime.ts";
|
} from "../contracts/external-contract-runtime.ts";
|
||||||
|
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
|
||||||
|
|
||||||
|
const COMPILED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
|
||||||
|
Object.freeze([]);
|
||||||
|
|
||||||
export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
|
export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] =
|
||||||
Object.freeze([]);
|
Object.freeze(
|
||||||
|
COMPILED_CONTRACT_CONTRIBUTIONS.filter((contribution) =>
|
||||||
|
INSTALLED_PRODUCT_FEATURE_IDS.includes(contribution.featureId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions(
|
export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions(
|
||||||
INSTALLED_CONTRACT_CONTRIBUTIONS,
|
INSTALLED_CONTRACT_CONTRIBUTIONS,
|
||||||
@@ -181,6 +236,10 @@ try {
|
|||||||
force: true,
|
force: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
await writeFile(
|
||||||
|
path.join(fixtureRoot, "src/features/installed-product-manifest.ts"),
|
||||||
|
emptyProductManifest,
|
||||||
|
);
|
||||||
await writeFile(
|
await writeFile(
|
||||||
path.join(fixtureRoot, "src/features/installed-feature-contracts.ts"),
|
path.join(fixtureRoot, "src/features/installed-feature-contracts.ts"),
|
||||||
emptyContracts,
|
emptyContracts,
|
||||||
@@ -202,6 +261,34 @@ try {
|
|||||||
emptyContractContributions,
|
emptyContractContributions,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Runtime profiles are product configuration. Once the feature is physically
|
||||||
|
// removed, its override key must disappear too or the built config still
|
||||||
|
// advertises a source capability that no longer exists.
|
||||||
|
for (const profile of [
|
||||||
|
"local",
|
||||||
|
"development",
|
||||||
|
"staging",
|
||||||
|
"production",
|
||||||
|
] as const) {
|
||||||
|
const profilePath = path.join(
|
||||||
|
fixtureRoot,
|
||||||
|
"config/runtime",
|
||||||
|
`${profile}.json`,
|
||||||
|
);
|
||||||
|
const runtimeConfig = JSON.parse(
|
||||||
|
await readFile(profilePath, "utf8"),
|
||||||
|
) as {
|
||||||
|
FEATURE_OVERRIDES?: Record<string, string>;
|
||||||
|
};
|
||||||
|
if (runtimeConfig.FEATURE_OVERRIDES) {
|
||||||
|
delete runtimeConfig.FEATURE_OVERRIDES["reference-feature"];
|
||||||
|
}
|
||||||
|
await writeFile(
|
||||||
|
profilePath,
|
||||||
|
`${JSON.stringify(runtimeConfig, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const retainedCriticalModules = coveragePolicy.criticalModules.filter(
|
const retainedCriticalModules = coveragePolicy.criticalModules.filter(
|
||||||
(modulePolicy) => !modulePolicy.path?.startsWith(`${featureSource}/`),
|
(modulePolicy) => !modulePolicy.path?.startsWith(`${featureSource}/`),
|
||||||
);
|
);
|
||||||
@@ -296,6 +383,12 @@ try {
|
|||||||
const removedCiScripts = new Set([
|
const removedCiScripts = new Set([
|
||||||
"check:types:fixture:feature-input",
|
"check:types:fixture:feature-input",
|
||||||
"check:types:fixture:reference-operation",
|
"check:types:fixture:reference-operation",
|
||||||
|
"check:types:fixture:http-operation-input",
|
||||||
|
"check:types:fixture:http-wire-mapper",
|
||||||
|
"check:types:fixture:http-operation-id",
|
||||||
|
"check:types:fixture:http-route-id",
|
||||||
|
"check:types:fixture:feature-contribution-input",
|
||||||
|
"check:types:fixture:feature-capability-selection",
|
||||||
"test:http-scenario-evidence",
|
"test:http-scenario-evidence",
|
||||||
"test:reference-feature",
|
"test:reference-feature",
|
||||||
]);
|
]);
|
||||||
@@ -370,6 +463,9 @@ try {
|
|||||||
["build", runPnpm("build")],
|
["build", runPnpm("build")],
|
||||||
];
|
];
|
||||||
const builtResidue: string[] = [];
|
const builtResidue: string[] = [];
|
||||||
|
const buildSucceeded =
|
||||||
|
checks.find(([name]) => name === "build")?.[1] === true;
|
||||||
|
if (buildSucceeded) {
|
||||||
for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) {
|
for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) {
|
||||||
if (!/\.(?:js|css|html|json)$/.test(file)) continue;
|
if (!/\.(?:js|css|html|json)$/.test(file)) continue;
|
||||||
const content = await readFile(file, "utf8");
|
const content = await readFile(file, "utf8");
|
||||||
@@ -379,6 +475,7 @@ try {
|
|||||||
builtResidue.push(path.relative(fixtureRoot, file));
|
builtResidue.push(path.relative(fixtureRoot, file));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const routeCatalog = await import(
|
const routeCatalog = await import(
|
||||||
`${new URL(
|
`${new URL(
|
||||||
"../src/features/installed-feature-contracts.ts",
|
"../src/features/installed-feature-contracts.ts",
|
||||||
|
|||||||
@@ -108,14 +108,12 @@ export class NativeInputFilePicker implements DisposableFilePicker {
|
|||||||
const addInputEvent = options.input.addEventListener;
|
const addInputEvent = options.input.addEventListener;
|
||||||
const removeInputEvent = options.input.removeEventListener;
|
const removeInputEvent = options.input.removeEventListener;
|
||||||
const getInputAttribute = options.input.getAttribute;
|
const getInputAttribute = options.input.getAttribute;
|
||||||
const showPicker = options.input.showPicker;
|
|
||||||
const click = options.input.click;
|
const click = options.input.click;
|
||||||
if (
|
if (
|
||||||
typeof addInputEvent !== "function" ||
|
typeof addInputEvent !== "function" ||
|
||||||
typeof removeInputEvent !== "function" ||
|
typeof removeInputEvent !== "function" ||
|
||||||
typeof getInputAttribute !== "function" ||
|
typeof getInputAttribute !== "function" ||
|
||||||
(typeof showPicker !== "function" &&
|
typeof click !== "function"
|
||||||
typeof click !== "function")
|
|
||||||
) {
|
) {
|
||||||
throw new TypeError("Native file input API is invalid.");
|
throw new TypeError("Native file input API is invalid.");
|
||||||
}
|
}
|
||||||
@@ -123,10 +121,11 @@ export class NativeInputFilePicker implements DisposableFilePicker {
|
|||||||
add: addInputEvent.bind(options.input),
|
add: addInputEvent.bind(options.input),
|
||||||
remove: removeInputEvent.bind(options.input),
|
remove: removeInputEvent.bind(options.input),
|
||||||
getAttribute: getInputAttribute.bind(options.input),
|
getAttribute: getInputAttribute.bind(options.input),
|
||||||
activate:
|
// The baseline <input type="file"> path intentionally uses click().
|
||||||
typeof showPicker === "function"
|
// showPicker() exists in multiple engines but is not a portable
|
||||||
? showPicker.bind(options.input)
|
// automation/event-interception contract. Enhanced native picking is a
|
||||||
: click.bind(options.input),
|
// separate capability owned by EnhancedFilePicker.
|
||||||
|
activate: click.bind(options.input),
|
||||||
});
|
});
|
||||||
const windowHost = options.window ?? globalThis.window;
|
const windowHost = options.window ?? globalThis.window;
|
||||||
if (windowHost) {
|
if (windowHost) {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { Result } from "../../contracts/result.ts";
|
|
||||||
import type {
|
import type {
|
||||||
MappingResult,
|
InstalledHttpContract,
|
||||||
} from "../../contracts/boundary-mapper.ts";
|
} from "../../contracts/external-contract-runtime.ts";
|
||||||
|
import type { Result } from "../../contracts/result.ts";
|
||||||
|
import type { MappingResult } from "../../contracts/boundary-mapper.ts";
|
||||||
import {
|
import {
|
||||||
createFailure,
|
createFailure,
|
||||||
kindForStatus,
|
kindForStatus,
|
||||||
@@ -10,55 +11,199 @@ import {
|
|||||||
type FailureKind,
|
type FailureKind,
|
||||||
} from "../../contracts/errors.ts";
|
} from "../../contracts/errors.ts";
|
||||||
import type { MutationIntent } from "../../contracts/mutation-intent.ts";
|
import type { MutationIntent } from "../../contracts/mutation-intent.ts";
|
||||||
import type { HttpExecutionOutcome } from "./http-execution-v3.ts";
|
import type {
|
||||||
|
HttpExecutionOutcome,
|
||||||
|
SafeResponseMetadata,
|
||||||
|
} from "./http-execution-v3.ts";
|
||||||
|
|
||||||
|
type ValidatorValue<Validator> =
|
||||||
|
Validator extends Readonly<{
|
||||||
|
safeParse(value: unknown): infer ParseResult;
|
||||||
|
}>
|
||||||
|
? Extract<
|
||||||
|
ParseResult,
|
||||||
|
Readonly<{ success: true; data: unknown }>
|
||||||
|
> extends Readonly<{ data: infer Value }>
|
||||||
|
? Value
|
||||||
|
: never
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export type HttpContractInput<Contract> =
|
||||||
|
Contract extends Readonly<{
|
||||||
|
contract: Readonly<{ inputValidator: infer Validator }>;
|
||||||
|
}>
|
||||||
|
? ValidatorValue<Validator>
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export type HttpContractWireOutput<Contract> =
|
||||||
|
Contract extends Readonly<{
|
||||||
|
contract: Readonly<{ outputValidator: infer Validator }>;
|
||||||
|
}>
|
||||||
|
? ValidatorValue<Validator>
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export type HttpContractProblem<Contract> =
|
||||||
|
Contract extends Readonly<{
|
||||||
|
contract: Readonly<{ problemValidator: infer Validator }>;
|
||||||
|
}>
|
||||||
|
? ValidatorValue<Validator>
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export type HttpContractOperationId<Contract> =
|
||||||
|
Contract extends Readonly<{
|
||||||
|
contract: Readonly<{ operationId: infer OperationId extends string }>;
|
||||||
|
}>
|
||||||
|
? OperationId
|
||||||
|
: never;
|
||||||
|
|
||||||
export type InstalledHttpOperationExecutor = Readonly<{
|
export type InstalledHttpOperationExecutor = Readonly<{
|
||||||
execute(
|
execute<Input, WireOutput, Problem>(
|
||||||
operationId: string,
|
contract: InstalledHttpContract<Input, WireOutput, Problem>,
|
||||||
input: unknown,
|
input: Input,
|
||||||
context: Readonly<{
|
context: Readonly<{
|
||||||
routeId: string;
|
routeId: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
intent?: MutationIntent;
|
intent?: MutationIntent;
|
||||||
}>,
|
}>,
|
||||||
): Promise<HttpExecutionOutcome<unknown, unknown>>;
|
): Promise<HttpExecutionOutcome<WireOutput, Problem>>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type FeatureHttpOperationSpec<Value> = Readonly<{
|
export type FeatureHttpProblemMapping = Readonly<{
|
||||||
operationId: string;
|
kind: FailureKind;
|
||||||
routeId: string;
|
code: string;
|
||||||
mapSuccess(value: unknown): MappingResult<Value>;
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type FeatureHttpOperation<Input, Value> =
|
type FeatureHttpOperationSpec<
|
||||||
FeatureHttpOperationSpec<Value> &
|
Contract,
|
||||||
|
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<{
|
Readonly<{
|
||||||
|
operationId: HttpContractOperationId<Contract>;
|
||||||
/**
|
/**
|
||||||
* Compile-time only carrier. Feature bindings remain plain frozen objects
|
* Compile-time only carrier. The runtime object has no extra type payload;
|
||||||
* at runtime while preserving each operation's input/value pair.
|
* the contract remains the authority for input/wire/problem types.
|
||||||
*/
|
*/
|
||||||
__types?: Readonly<{
|
__types?: Readonly<{
|
||||||
input: Input;
|
|
||||||
value: Value;
|
value: Value;
|
||||||
}>;
|
}>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export function defineFeatureHttpOperation<Input, Value>(
|
type ContractValidity<Contract> =
|
||||||
spec: FeatureHttpOperationSpec<Value>,
|
HttpContractOperationId<Contract> extends never
|
||||||
): FeatureHttpOperation<Input, Value> {
|
? never
|
||||||
return Object.freeze(spec) as FeatureHttpOperation<Input, Value>;
|
: HttpContractInput<Contract> extends never
|
||||||
|
? never
|
||||||
|
: HttpContractWireOutput<Contract> extends never
|
||||||
|
? never
|
||||||
|
: HttpContractProblem<Contract> extends never
|
||||||
|
? never
|
||||||
|
: unknown;
|
||||||
|
|
||||||
|
function defineFeatureHttpOperationForRoute<
|
||||||
|
const RouteId extends string,
|
||||||
|
const Contract,
|
||||||
|
Value,
|
||||||
|
>(
|
||||||
|
spec: FeatureHttpOperationSpec<Contract, Value, RouteId> &
|
||||||
|
ContractValidity<Contract>,
|
||||||
|
): FeatureHttpOperation<Contract, Value, RouteId> {
|
||||||
|
return Object.freeze({
|
||||||
|
...spec,
|
||||||
|
operationId: (spec.contract as Readonly<{
|
||||||
|
contract: Readonly<{ operationId: HttpContractOperationId<Contract> }>;
|
||||||
|
}>).contract.operationId,
|
||||||
|
}) as FeatureHttpOperation<Contract, Value, RouteId>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type OperationInput<Operation> =
|
export function defineFeatureHttpOperation<
|
||||||
Operation extends FeatureHttpOperation<infer Input, unknown> ? Input : never;
|
const Contract,
|
||||||
|
Value,
|
||||||
|
const RouteId extends string,
|
||||||
|
>(
|
||||||
|
spec: FeatureHttpOperationSpec<Contract, Value, RouteId> &
|
||||||
|
ContractValidity<Contract>,
|
||||||
|
): FeatureHttpOperation<Contract, Value, RouteId> {
|
||||||
|
return defineFeatureHttpOperationForRoute(spec);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature-local route typing without introducing a dependency from the HTTP
|
||||||
|
* capability to the presentation route registry.
|
||||||
|
*/
|
||||||
|
export function defineFeatureHttpOperationForRoutes<
|
||||||
|
const RouteId extends string,
|
||||||
|
>() {
|
||||||
|
return function defineRouteBoundFeatureHttpOperation<
|
||||||
|
const Contract,
|
||||||
|
Value,
|
||||||
|
>(
|
||||||
|
spec: FeatureHttpOperationSpec<Contract, Value, RouteId> &
|
||||||
|
ContractValidity<Contract>,
|
||||||
|
): FeatureHttpOperation<Contract, Value, RouteId> {
|
||||||
|
return defineFeatureHttpOperationForRoute(spec);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeatureHttpOperationShape = Readonly<{
|
||||||
|
contract: object;
|
||||||
|
operationId: string;
|
||||||
|
routeId: string;
|
||||||
|
mapSuccess: (...args: never[]) => unknown;
|
||||||
|
mapProblem?: (...args: never[]) => unknown;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type OperationContract<Operation> =
|
||||||
|
Operation extends Readonly<{ contract: infer Contract }> ? Contract : never;
|
||||||
|
|
||||||
|
type OperationInput<Operation> = HttpContractInput<OperationContract<Operation>>;
|
||||||
|
type OperationWireOutput<Operation> =
|
||||||
|
HttpContractWireOutput<OperationContract<Operation>>;
|
||||||
|
type OperationProblem<Operation> =
|
||||||
|
HttpContractProblem<OperationContract<Operation>>;
|
||||||
|
|
||||||
type OperationValue<Operation> =
|
type OperationValue<Operation> =
|
||||||
Operation extends FeatureHttpOperation<unknown, infer Value> ? Value : never;
|
Operation extends FeatureHttpOperation<infer _Contract, infer Value, string>
|
||||||
|
? Value
|
||||||
|
: never;
|
||||||
|
|
||||||
|
type InvalidOperationRegistryKeys<
|
||||||
|
Operations extends Readonly<Record<string, FeatureHttpOperationShape>>,
|
||||||
|
> = {
|
||||||
|
[OperationId in keyof Operations & string]:
|
||||||
|
OperationId extends Operations[OperationId]["operationId"]
|
||||||
|
? Operations[OperationId]["operationId"] extends OperationId
|
||||||
|
? never
|
||||||
|
: OperationId
|
||||||
|
: OperationId;
|
||||||
|
}[keyof Operations & string];
|
||||||
|
|
||||||
|
type ValidOperationRegistry<
|
||||||
|
Operations extends Readonly<Record<string, FeatureHttpOperationShape>>,
|
||||||
|
> = InvalidOperationRegistryKeys<Operations> extends never
|
||||||
|
? unknown
|
||||||
|
: Readonly<{
|
||||||
|
__operationRegistryKeyMismatch: InvalidOperationRegistryKeys<Operations>;
|
||||||
|
}>;
|
||||||
|
|
||||||
export type FeatureHttpBinding<
|
export type FeatureHttpBinding<
|
||||||
Operations extends Readonly<
|
Operations extends Readonly<Record<string, FeatureHttpOperationShape>>,
|
||||||
Record<string, FeatureHttpOperation<unknown, unknown>>
|
|
||||||
>,
|
|
||||||
> = Readonly<{
|
> = Readonly<{
|
||||||
execute<OperationId extends keyof Operations & string>(
|
execute<OperationId extends keyof Operations & string>(
|
||||||
operationId: OperationId,
|
operationId: OperationId,
|
||||||
@@ -73,18 +218,15 @@ export type FeatureHttpBinding<
|
|||||||
/**
|
/**
|
||||||
* Capability-specific feature binding for the installed HTTP runtime.
|
* Capability-specific feature binding for the installed HTTP runtime.
|
||||||
*
|
*
|
||||||
* The platform owns transport/outcome normalization. A feature contributes only
|
* The installed contract is the single type authority. A feature contributes
|
||||||
* operation identity, route identity, typed input and its wire-to-domain mapper.
|
* only a route identity plus wire-to-domain/problem interpretation. Transport,
|
||||||
* This keeps HTTP lifecycle/failure semantics out of feature application code
|
* retry, validation and effect-certainty stay in the reusable HTTP capability.
|
||||||
* without collapsing storage/realtime/transfer into a universal repository.
|
|
||||||
*/
|
*/
|
||||||
export function createFeatureHttpBinding<
|
export function createFeatureHttpBinding<
|
||||||
const Operations extends Readonly<
|
const Operations extends Readonly<Record<string, FeatureHttpOperationShape>>,
|
||||||
Record<string, FeatureHttpOperation<unknown, unknown>>
|
|
||||||
>,
|
|
||||||
>(
|
>(
|
||||||
executor: InstalledHttpOperationExecutor,
|
executor: InstalledHttpOperationExecutor,
|
||||||
operations: Operations,
|
operations: Operations & ValidOperationRegistry<Operations>,
|
||||||
): FeatureHttpBinding<Operations> {
|
): FeatureHttpBinding<Operations> {
|
||||||
for (const [registryId, operation] of Object.entries(operations)) {
|
for (const [registryId, operation] of Object.entries(operations)) {
|
||||||
if (registryId !== operation.operationId) {
|
if (registryId !== operation.operationId) {
|
||||||
@@ -104,28 +246,47 @@ export function createFeatureHttpBinding<
|
|||||||
}> = {},
|
}> = {},
|
||||||
): Promise<Result<OperationValue<Operations[OperationId]>, ApiFailure>> {
|
): Promise<Result<OperationValue<Operations[OperationId]>, ApiFailure>> {
|
||||||
const operation = operations[operationId];
|
const operation = operations[operationId];
|
||||||
const outcome = await executor.execute(operation.operationId, input, {
|
type SelectedOperation = Operations[OperationId];
|
||||||
|
type Input = OperationInput<SelectedOperation>;
|
||||||
|
type WireOutput = OperationWireOutput<SelectedOperation>;
|
||||||
|
type Problem = OperationProblem<SelectedOperation>;
|
||||||
|
type Value = OperationValue<SelectedOperation>;
|
||||||
|
|
||||||
|
const contract = operation.contract as InstalledHttpContract<
|
||||||
|
Input,
|
||||||
|
WireOutput,
|
||||||
|
Problem
|
||||||
|
>;
|
||||||
|
const outcome = await executor.execute(contract, input, {
|
||||||
routeId: operation.routeId,
|
routeId: operation.routeId,
|
||||||
...(context.signal === undefined ? {} : { signal: context.signal }),
|
...(context.signal === undefined ? {} : { signal: context.signal }),
|
||||||
...(context.intent === undefined ? {} : { intent: context.intent }),
|
...(context.intent === undefined ? {} : { intent: context.intent }),
|
||||||
});
|
});
|
||||||
return projectExecutionOutcome<
|
return projectExecutionOutcome(
|
||||||
OperationValue<Operations[OperationId]>
|
|
||||||
>(
|
|
||||||
operation.operationId,
|
operation.operationId,
|
||||||
operation.mapSuccess as (
|
operation.mapSuccess as (value: WireOutput) => MappingResult<Value>,
|
||||||
value: unknown,
|
operation.mapProblem as
|
||||||
) => MappingResult<OperationValue<Operations[OperationId]>>,
|
| ((
|
||||||
|
problem: Problem,
|
||||||
|
metadata: SafeResponseMetadata,
|
||||||
|
) => FeatureHttpProblemMapping | undefined)
|
||||||
|
| undefined,
|
||||||
outcome,
|
outcome,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectExecutionOutcome<Value>(
|
function projectExecutionOutcome<WireOutput, Problem, Value>(
|
||||||
operationId: string,
|
operationId: string,
|
||||||
mapSuccess: (value: unknown) => MappingResult<Value>,
|
mapSuccess: (value: WireOutput) => MappingResult<Value>,
|
||||||
outcome: HttpExecutionOutcome<unknown, unknown>,
|
mapProblem:
|
||||||
|
| ((
|
||||||
|
problem: Problem,
|
||||||
|
metadata: SafeResponseMetadata,
|
||||||
|
) => FeatureHttpProblemMapping | undefined)
|
||||||
|
| undefined,
|
||||||
|
outcome: HttpExecutionOutcome<WireOutput, Problem>,
|
||||||
): Result<Value, ApiFailure> {
|
): Result<Value, ApiFailure> {
|
||||||
switch (outcome.kind) {
|
switch (outcome.kind) {
|
||||||
case "SUCCESS": {
|
case "SUCCESS": {
|
||||||
@@ -139,13 +300,15 @@ function projectExecutionOutcome<Value>(
|
|||||||
{ effect: outcome.effect },
|
{ effect: outcome.effect },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "PROBLEM":
|
case "PROBLEM": {
|
||||||
|
const mapped = mapProblem?.(outcome.problem, outcome.metadata);
|
||||||
return failure(
|
return failure(
|
||||||
kindForStatus(outcome.metadata.status),
|
mapped?.kind ?? kindForStatus(outcome.metadata.status),
|
||||||
operationId,
|
operationId,
|
||||||
"CONTRACT_PROBLEM",
|
mapped?.code ?? "CONTRACT_PROBLEM",
|
||||||
{ httpStatus: outcome.metadata.status, effect: outcome.effect },
|
{ httpStatus: outcome.metadata.status, effect: outcome.effect },
|
||||||
);
|
);
|
||||||
|
}
|
||||||
case "UNAUTHENTICATED":
|
case "UNAUTHENTICATED":
|
||||||
return failure("AUTH_REQUIRED", operationId, "UNAUTHENTICATED", {
|
return failure("AUTH_REQUIRED", operationId, "UNAUTHENTICATED", {
|
||||||
effect: outcome.effect,
|
effect: outcome.effect,
|
||||||
|
|||||||
@@ -22,8 +22,14 @@ export {
|
|||||||
export {
|
export {
|
||||||
createFeatureHttpBinding,
|
createFeatureHttpBinding,
|
||||||
defineFeatureHttpOperation,
|
defineFeatureHttpOperation,
|
||||||
|
defineFeatureHttpOperationForRoutes,
|
||||||
type FeatureHttpBinding,
|
type FeatureHttpBinding,
|
||||||
type FeatureHttpOperation,
|
type FeatureHttpOperation,
|
||||||
|
type FeatureHttpProblemMapping,
|
||||||
|
type HttpContractInput,
|
||||||
|
type HttpContractOperationId,
|
||||||
|
type HttpContractProblem,
|
||||||
|
type HttpContractWireOutput,
|
||||||
type InstalledHttpOperationExecutor,
|
type InstalledHttpOperationExecutor,
|
||||||
} from "./feature-http-binding.ts";
|
} from "./feature-http-binding.ts";
|
||||||
/** V3 `attachCredentials` 콜백이 반환해야 하는 결과 타입. */
|
/** V3 `attachCredentials` 콜백이 반환해야 하는 결과 타입. */
|
||||||
|
|||||||
@@ -21,9 +21,23 @@ export type IndexedDbSynchronizationState =
|
|||||||
| "CONFIRMED";
|
| "CONFIRMED";
|
||||||
|
|
||||||
export type IndexedDbConnectionStatus =
|
export type IndexedDbConnectionStatus =
|
||||||
| Readonly<{ kind: "CLOSED"; reason: "NOT_OPENED" | "VERSION_CHANGE" | "FORCED" }>
|
| Readonly<{
|
||||||
|
kind: "CLOSED";
|
||||||
|
/**
|
||||||
|
* NOT_OPENED means the current open attempt has settled without a live
|
||||||
|
* connection. A blocked upgrade may therefore be observed as BLOCKED
|
||||||
|
* while it is waiting, then transition to CLOSED/NOT_OPENED when its
|
||||||
|
* blocked deadline fails closed.
|
||||||
|
*/
|
||||||
|
reason: "NOT_OPENED" | "VERSION_CHANGE" | "FORCED";
|
||||||
|
}>
|
||||||
| Readonly<{ kind: "OPENING"; targetVersion: number }>
|
| Readonly<{ kind: "OPENING"; targetVersion: number }>
|
||||||
| Readonly<{
|
| Readonly<{
|
||||||
|
/**
|
||||||
|
* A live open/upgrade attempt is currently waiting for another
|
||||||
|
* IndexedDB context. BLOCKED is transient and is not the settled state
|
||||||
|
* after the blocked deadline has expired.
|
||||||
|
*/
|
||||||
kind: "BLOCKED";
|
kind: "BLOCKED";
|
||||||
currentVersion: number;
|
currentVersion: number;
|
||||||
targetVersion: number;
|
targetVersion: number;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
createContractHttpExecutor,
|
createContractHttpExecutor,
|
||||||
createHttpClient,
|
createHttpClient,
|
||||||
type HttpExecutionObservation,
|
type HttpExecutionObservation,
|
||||||
|
type InstalledHttpOperationExecutor,
|
||||||
} from "../adapters/http/index.ts";
|
} from "../adapters/http/index.ts";
|
||||||
import {
|
import {
|
||||||
attemptBucket,
|
attemptBucket,
|
||||||
@@ -480,19 +481,13 @@ export async function createRuntimeAdapters(
|
|||||||
},
|
},
|
||||||
observe: createHttpObservationProjector({ diagnostics, telemetry }),
|
observe: createHttpObservationProjector({ diagnostics, telemetry }),
|
||||||
});
|
});
|
||||||
const contractOperations = Object.freeze({
|
const contractOperations: InstalledHttpOperationExecutor = Object.freeze({
|
||||||
async execute(
|
async execute(operation, input, executionContext) {
|
||||||
operationId: string,
|
const installed =
|
||||||
input: unknown,
|
COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(
|
||||||
executionContext: Readonly<{
|
operation.contract.operationId,
|
||||||
routeId: string;
|
);
|
||||||
signal?: AbortSignal;
|
if (!installed) {
|
||||||
intent?: MutationIntent;
|
|
||||||
}>,
|
|
||||||
) {
|
|
||||||
const operation =
|
|
||||||
COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(operationId);
|
|
||||||
if (!operation) {
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
kind: "CONTRACT_VIOLATION" as const,
|
kind: "CONTRACT_VIOLATION" as const,
|
||||||
effect: "NOT_STARTED" as const,
|
effect: "NOT_STARTED" as const,
|
||||||
@@ -502,7 +497,11 @@ export async function createRuntimeAdapters(
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const outcome = await contractHttp.execute(operation, input, {
|
// composeContractContributions deliberately snapshots every installed
|
||||||
|
// contract. Runtime authority is therefore the validated registry entry,
|
||||||
|
// not object identity with the feature-owned source declaration.
|
||||||
|
const executableContract = installed as typeof operation;
|
||||||
|
const outcome = await contractHttp.execute(executableContract, input, {
|
||||||
routeId: executionContext.routeId,
|
routeId: executionContext.routeId,
|
||||||
scope: serverStateScope.getSnapshot(),
|
scope: serverStateScope.getSnapshot(),
|
||||||
...(executionContext.signal === undefined
|
...(executionContext.signal === undefined
|
||||||
@@ -519,7 +518,7 @@ export async function createRuntimeAdapters(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const featureInputs = createInstalledFeatureInputs({
|
const featureInputs = createInstalledFeatureInputs({
|
||||||
contractOperations,
|
http: contractOperations,
|
||||||
});
|
});
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import type {
|
||||||
|
ApplicationFeatureId,
|
||||||
|
ApplicationFeatureInputs,
|
||||||
|
} from "../application/ports/in/application-api.ts";
|
||||||
|
import type {
|
||||||
|
InstalledHttpOperationExecutor,
|
||||||
|
} from "../adapters/http/index.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Features augment this registry with the repository identities they consume.
|
||||||
|
* The provider is a composition capability; feature use cases never import it.
|
||||||
|
*/
|
||||||
|
export interface FeatureIndexedDbRepositories {}
|
||||||
|
|
||||||
|
export type FeatureIndexedDbRepositoryId = Extract<
|
||||||
|
keyof FeatureIndexedDbRepositories,
|
||||||
|
string
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type IndexedDbRepositoryProvider = Readonly<{
|
||||||
|
get<RepositoryId extends FeatureIndexedDbRepositoryId>(
|
||||||
|
repositoryId: RepositoryId,
|
||||||
|
): FeatureIndexedDbRepositories[RepositoryId];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function createIndexedDbRepositoryProvider(
|
||||||
|
repositories: Readonly<Partial<FeatureIndexedDbRepositories>>,
|
||||||
|
): IndexedDbRepositoryProvider {
|
||||||
|
const snapshot = Object.freeze({ ...repositories });
|
||||||
|
return Object.freeze({
|
||||||
|
get<RepositoryId extends FeatureIndexedDbRepositoryId>(
|
||||||
|
repositoryId: RepositoryId,
|
||||||
|
): FeatureIndexedDbRepositories[RepositoryId] {
|
||||||
|
const repository = snapshot[repositoryId];
|
||||||
|
if (!repository) {
|
||||||
|
throw new TypeError(
|
||||||
|
`IndexedDB repository is not installed: ${repositoryId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return repository as FeatureIndexedDbRepositories[RepositoryId];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FeaturePlatformCapabilities = Readonly<{
|
||||||
|
http: InstalledHttpOperationExecutor;
|
||||||
|
indexedDb: IndexedDbRepositoryProvider;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type FeaturePlatformCapabilityId =
|
||||||
|
keyof FeaturePlatformCapabilities;
|
||||||
|
|
||||||
|
export type FeatureCapabilitySelection<
|
||||||
|
Needs extends readonly FeaturePlatformCapabilityId[],
|
||||||
|
> = Pick<FeaturePlatformCapabilities, Needs[number]>;
|
||||||
|
|
||||||
|
const FEATURE_ADAPTER_CONTRIBUTION = Symbol(
|
||||||
|
"feature-adapter-contribution",
|
||||||
|
);
|
||||||
|
|
||||||
|
type FeatureAdapterContributionDefinition<
|
||||||
|
FeatureId extends ApplicationFeatureId,
|
||||||
|
Needs extends readonly FeaturePlatformCapabilityId[],
|
||||||
|
> = Readonly<{
|
||||||
|
featureId: FeatureId;
|
||||||
|
needs: Needs;
|
||||||
|
createInput(
|
||||||
|
context: FeatureCapabilitySelection<Needs>,
|
||||||
|
): Readonly<{
|
||||||
|
featureId: FeatureId;
|
||||||
|
input: ApplicationFeatureInputs[FeatureId];
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type FeatureAdapterContribution<
|
||||||
|
FeatureId extends ApplicationFeatureId,
|
||||||
|
Needs extends readonly FeaturePlatformCapabilityId[],
|
||||||
|
> = FeatureAdapterContributionDefinition<FeatureId, Needs> &
|
||||||
|
Readonly<{
|
||||||
|
[FEATURE_ADAPTER_CONTRIBUTION]: true;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function defineFeatureAdapterContribution<
|
||||||
|
const FeatureId extends ApplicationFeatureId,
|
||||||
|
const Needs extends readonly FeaturePlatformCapabilityId[],
|
||||||
|
>(
|
||||||
|
contribution: FeatureAdapterContributionDefinition<FeatureId, Needs>,
|
||||||
|
): FeatureAdapterContribution<FeatureId, Needs> {
|
||||||
|
return Object.freeze({
|
||||||
|
...contribution,
|
||||||
|
needs: Object.freeze([...contribution.needs]) as unknown as Needs,
|
||||||
|
[FEATURE_ADAPTER_CONTRIBUTION]: true as const,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContributionShape = Readonly<{
|
||||||
|
[FEATURE_ADAPTER_CONTRIBUTION]: true;
|
||||||
|
featureId: ApplicationFeatureId;
|
||||||
|
needs: readonly FeaturePlatformCapabilityId[];
|
||||||
|
createInput: (...args: never[]) => unknown;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type ContributionNeeds<Contribution> =
|
||||||
|
Contribution extends Readonly<{
|
||||||
|
needs: readonly (infer Capability)[];
|
||||||
|
}>
|
||||||
|
? Extract<Capability, FeaturePlatformCapabilityId>
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export type ContributionsCapabilityContext<
|
||||||
|
Contributions extends readonly ContributionShape[],
|
||||||
|
> = Pick<
|
||||||
|
FeaturePlatformCapabilities,
|
||||||
|
ContributionNeeds<Contributions[number]>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type InstalledFeatureInputs = Readonly<
|
||||||
|
Partial<ApplicationFeatureInputs>
|
||||||
|
>;
|
||||||
|
|
||||||
|
function setFeatureInput<FeatureId extends ApplicationFeatureId>(
|
||||||
|
target: Partial<ApplicationFeatureInputs>,
|
||||||
|
featureId: FeatureId,
|
||||||
|
input: ApplicationFeatureInputs[FeatureId],
|
||||||
|
): void {
|
||||||
|
const writable = target as Record<
|
||||||
|
ApplicationFeatureId,
|
||||||
|
ApplicationFeatureInputs[ApplicationFeatureId]
|
||||||
|
>;
|
||||||
|
writable[featureId] = input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invokeContribution<
|
||||||
|
FeatureId extends ApplicationFeatureId,
|
||||||
|
Needs extends readonly FeaturePlatformCapabilityId[],
|
||||||
|
>(
|
||||||
|
contribution: FeatureAdapterContribution<FeatureId, Needs>,
|
||||||
|
context: FeaturePlatformCapabilities,
|
||||||
|
) {
|
||||||
|
return contribution.createInput(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamic catalog assembly stays centralized here. Type authority lives at each
|
||||||
|
* feature contribution; the only dynamic write is isolated in setFeatureInput.
|
||||||
|
*/
|
||||||
|
export function composeFeatureAdapterInputs<
|
||||||
|
const Contributions extends readonly ContributionShape[],
|
||||||
|
>(
|
||||||
|
contributions: Contributions,
|
||||||
|
enabledFeatureIds: readonly string[],
|
||||||
|
context: ContributionsCapabilityContext<Contributions>,
|
||||||
|
): InstalledFeatureInputs {
|
||||||
|
const enabled = new Set<string>(enabledFeatureIds);
|
||||||
|
const result: Partial<ApplicationFeatureInputs> = {};
|
||||||
|
|
||||||
|
for (const contribution of contributions) {
|
||||||
|
if (contribution[FEATURE_ADAPTER_CONTRIBUTION] !== true) {
|
||||||
|
throw new TypeError(
|
||||||
|
"Feature adapter contribution must be defined through defineFeatureAdapterContribution()",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!enabled.has(contribution.featureId)) continue;
|
||||||
|
|
||||||
|
const installed = invokeContribution(
|
||||||
|
contribution as unknown as FeatureAdapterContribution<
|
||||||
|
ApplicationFeatureId,
|
||||||
|
readonly FeaturePlatformCapabilityId[]
|
||||||
|
>,
|
||||||
|
context as FeaturePlatformCapabilities,
|
||||||
|
);
|
||||||
|
setFeatureInput(result, installed.featureId, installed.input);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze(result);
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { ApplicationFeatureInputs } from "../application/ports/in/application-api.ts";
|
import {
|
||||||
|
composeFeatureAdapterInputs,
|
||||||
|
type ContributionsCapabilityContext,
|
||||||
|
type InstalledFeatureInputs,
|
||||||
|
} from "./feature-adapter-contribution.ts";
|
||||||
import { REFERENCE_FEATURE_ADAPTER_CONTRIBUTION } from "./reference-feature/adapters/create-reference-feature-input.ts";
|
import { REFERENCE_FEATURE_ADAPTER_CONTRIBUTION } from "./reference-feature/adapters/create-reference-feature-input.ts";
|
||||||
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
|
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
|
||||||
|
|
||||||
@@ -6,28 +10,22 @@ const COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS = Object.freeze([
|
|||||||
REFERENCE_FEATURE_ADAPTER_CONTRIBUTION,
|
REFERENCE_FEATURE_ADAPTER_CONTRIBUTION,
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
type FeatureAdapterContext = Parameters<
|
type InstalledFeatureCapabilityContext =
|
||||||
(typeof COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS)[number]["createInput"]
|
ContributionsCapabilityContext<
|
||||||
>[0];
|
typeof COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS
|
||||||
|
>;
|
||||||
type InstalledFeatureInputs = Readonly<Partial<ApplicationFeatureInputs>>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Central adapter composition only selects and aggregates feature-owned
|
* Central adapter composition only selects and aggregates feature-owned
|
||||||
* contributions. The feature owns how its application input is bound to
|
* contributions. The feature owns how its application input is bound to the
|
||||||
* reusable platform capabilities.
|
* reusable platform capabilities declared by its `needs` list.
|
||||||
*/
|
*/
|
||||||
export function createInstalledFeatureInputs(
|
export function createInstalledFeatureInputs(
|
||||||
context: FeatureAdapterContext,
|
context: InstalledFeatureCapabilityContext,
|
||||||
): InstalledFeatureInputs {
|
): InstalledFeatureInputs {
|
||||||
const entries = COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS
|
return composeFeatureAdapterInputs(
|
||||||
.filter((contribution) =>
|
COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS,
|
||||||
INSTALLED_PRODUCT_FEATURE_IDS.includes(contribution.featureId),
|
INSTALLED_PRODUCT_FEATURE_IDS,
|
||||||
)
|
context,
|
||||||
.map((contribution) => {
|
);
|
||||||
const installed = contribution.createInput(context);
|
|
||||||
return [installed.featureId, installed.input] as const;
|
|
||||||
});
|
|
||||||
|
|
||||||
return Object.freeze(Object.fromEntries(entries)) as InstalledFeatureInputs;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type {
|
||||||
|
IndexedDbRepositoryPort,
|
||||||
|
} from "../../../application/ports/browser-file-storage/index.ts";
|
||||||
|
import {
|
||||||
|
defineFeatureAdapterContribution,
|
||||||
|
} from "../../feature-adapter-contribution.ts";
|
||||||
|
import type {} from "../../feature-adapter-contribution.ts";
|
||||||
|
import {
|
||||||
|
createLocalDraftFeatureInput,
|
||||||
|
} from "../application/local-draft-api.ts";
|
||||||
|
import type { LocalDraft } from "../domain/local-draft.ts";
|
||||||
|
import {
|
||||||
|
LOCAL_DRAFT_FEATURE_ID,
|
||||||
|
LOCAL_DRAFT_REPOSITORY_ID,
|
||||||
|
} from "../contracts/local-draft-contract.ts";
|
||||||
|
import { createLocalDraftStore } from "./local-draft-indexeddb-store.ts";
|
||||||
|
|
||||||
|
declare module "../../feature-adapter-contribution.ts" {
|
||||||
|
interface FeatureIndexedDbRepositories {
|
||||||
|
"local-draft": IndexedDbRepositoryPort<LocalDraft, never>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLocalDraftFeatureInstalledInput(context: Readonly<{
|
||||||
|
indexedDb: Readonly<{
|
||||||
|
get(repositoryId: "local-draft"): IndexedDbRepositoryPort<LocalDraft, never>;
|
||||||
|
}>;
|
||||||
|
}>) {
|
||||||
|
const repository = context.indexedDb.get(LOCAL_DRAFT_REPOSITORY_ID);
|
||||||
|
return Object.freeze({
|
||||||
|
featureId: LOCAL_DRAFT_FEATURE_ID,
|
||||||
|
input: createLocalDraftFeatureInput(createLocalDraftStore(repository)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION =
|
||||||
|
defineFeatureAdapterContribution({
|
||||||
|
featureId: LOCAL_DRAFT_FEATURE_ID,
|
||||||
|
needs: ["indexedDb"] as const,
|
||||||
|
createInput: createLocalDraftFeatureInstalledInput,
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import type {
|
||||||
|
BrowserDataFailure,
|
||||||
|
IndexedDbRepositoryPort,
|
||||||
|
} from "../../../application/ports/browser-file-storage/index.ts";
|
||||||
|
import {
|
||||||
|
createFailure,
|
||||||
|
type AppFailure,
|
||||||
|
type FailureKind,
|
||||||
|
} from "../../../contracts/errors.ts";
|
||||||
|
import type { Result } from "../../../contracts/result.ts";
|
||||||
|
import type {
|
||||||
|
LocalDraftStore,
|
||||||
|
LocalDraftRecord,
|
||||||
|
RemoveLocalDraftCommand,
|
||||||
|
SaveLocalDraftCommand,
|
||||||
|
} from "../application/local-draft-api.ts";
|
||||||
|
import type { LocalDraft } from "../domain/local-draft.ts";
|
||||||
|
|
||||||
|
function failureKind(failure: BrowserDataFailure): FailureKind {
|
||||||
|
if (failure.code === "QUOTA_EXCEEDED") return "STORAGE_QUOTA_EXCEEDED";
|
||||||
|
if (failure.code === "CONFLICT") return "CONFLICT";
|
||||||
|
if (failure.code === "ABORTED") return "REQUEST_ABORTED";
|
||||||
|
return "STORAGE_UNAVAILABLE";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapFailure(
|
||||||
|
failure: BrowserDataFailure,
|
||||||
|
operationId: string,
|
||||||
|
): AppFailure {
|
||||||
|
return createFailure(failureKind(failure), operationId, 0, {
|
||||||
|
code: `INDEXEDDB_${failure.code}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function failed<Value>(
|
||||||
|
failure: BrowserDataFailure,
|
||||||
|
operationId: string,
|
||||||
|
): Result<Value> {
|
||||||
|
return Object.freeze({
|
||||||
|
ok: false as const,
|
||||||
|
error: mapFailure(failure, operationId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature-owned adapter over the reusable IndexedDB repository capability.
|
||||||
|
* Native IDB objects, migrations, connection leases and quota internals do not
|
||||||
|
* cross this file.
|
||||||
|
*/
|
||||||
|
export function createLocalDraftStore(
|
||||||
|
repository: IndexedDbRepositoryPort<LocalDraft, never>,
|
||||||
|
): LocalDraftStore {
|
||||||
|
return Object.freeze({
|
||||||
|
async save(command: SaveLocalDraftCommand, signal?: AbortSignal) {
|
||||||
|
const result = await repository.compareAndSwap({
|
||||||
|
key: command.draft.draftId,
|
||||||
|
value: command.draft,
|
||||||
|
expectedRevision: command.expectedRevision,
|
||||||
|
idempotencyKey: command.idempotencyKey,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
return result.ok
|
||||||
|
? Object.freeze({
|
||||||
|
ok: true as const,
|
||||||
|
value: Object.freeze({ revision: result.value.revision }),
|
||||||
|
})
|
||||||
|
: failed<Readonly<{ revision: number }>>(
|
||||||
|
result.error,
|
||||||
|
"LOCAL_DRAFT_SAVE",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async find(draftId: string, signal?: AbortSignal) {
|
||||||
|
const result = await repository.read(draftId, signal);
|
||||||
|
if (!result.ok) {
|
||||||
|
return failed<LocalDraftRecord | null>(
|
||||||
|
result.error,
|
||||||
|
"LOCAL_DRAFT_FIND",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
ok: true as const,
|
||||||
|
value:
|
||||||
|
result.value === null
|
||||||
|
? null
|
||||||
|
: Object.freeze({
|
||||||
|
draft: result.value.value,
|
||||||
|
revision: result.value.revision,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(command: RemoveLocalDraftCommand, signal?: AbortSignal) {
|
||||||
|
const result = await repository.remove({
|
||||||
|
key: command.draftId,
|
||||||
|
expectedRevision: command.expectedRevision,
|
||||||
|
idempotencyKey: command.idempotencyKey,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
return result.ok
|
||||||
|
? Object.freeze({ ok: true as const, value: undefined })
|
||||||
|
: failed<void>(result.error, "LOCAL_DRAFT_REMOVE");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { Result } from "../../../contracts/result.ts";
|
||||||
|
import type {} from "../../../application/ports/in/application-api.ts";
|
||||||
|
import type { LocalDraft } from "../domain/local-draft.ts";
|
||||||
|
|
||||||
|
export type LocalDraftRecord = Readonly<{
|
||||||
|
draft: LocalDraft;
|
||||||
|
revision: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type SaveLocalDraftCommand = Readonly<{
|
||||||
|
draft: LocalDraft;
|
||||||
|
expectedRevision: number | null;
|
||||||
|
idempotencyKey: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type RemoveLocalDraftCommand = Readonly<{
|
||||||
|
draftId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
idempotencyKey: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type LocalDraftStore = Readonly<{
|
||||||
|
save(
|
||||||
|
command: SaveLocalDraftCommand,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Result<Readonly<{ revision: number }>>>;
|
||||||
|
find(
|
||||||
|
draftId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Result<LocalDraftRecord | null>>;
|
||||||
|
remove(
|
||||||
|
command: RemoveLocalDraftCommand,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Result<void>>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type LocalDraftFeatureInput = Readonly<{
|
||||||
|
saveDraft(
|
||||||
|
command: SaveLocalDraftCommand,
|
||||||
|
context?: Readonly<{ signal?: AbortSignal }>,
|
||||||
|
): Promise<Result<Readonly<{ revision: number }>>>;
|
||||||
|
findDraft(
|
||||||
|
draftId: string,
|
||||||
|
context?: Readonly<{ signal?: AbortSignal }>,
|
||||||
|
): Promise<Result<LocalDraftRecord | null>>;
|
||||||
|
removeDraft(
|
||||||
|
command: RemoveLocalDraftCommand,
|
||||||
|
context?: Readonly<{ signal?: AbortSignal }>,
|
||||||
|
): Promise<Result<void>>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
declare module "../../../application/ports/in/application-api.ts" {
|
||||||
|
interface ApplicationFeatureInputs {
|
||||||
|
"local-draft": LocalDraftFeatureInput;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLocalDraftFeatureInput(
|
||||||
|
store: LocalDraftStore,
|
||||||
|
): LocalDraftFeatureInput {
|
||||||
|
return Object.freeze({
|
||||||
|
saveDraft(command, context) {
|
||||||
|
return store.save(command, context?.signal);
|
||||||
|
},
|
||||||
|
findDraft(draftId, context) {
|
||||||
|
return store.find(draftId, context?.signal);
|
||||||
|
},
|
||||||
|
removeDraft(command, context) {
|
||||||
|
return store.remove(command, context?.signal);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const LOCAL_DRAFT_FEATURE_ID = "local-draft" as const;
|
||||||
|
export const LOCAL_DRAFT_REPOSITORY_ID = "local-draft" as const;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export type LocalDraft = Readonly<{
|
||||||
|
draftId: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function createLocalDraft(values: LocalDraft): LocalDraft {
|
||||||
|
const draftId = values.draftId.trim();
|
||||||
|
const title = values.title.trim();
|
||||||
|
if (!draftId || !title) {
|
||||||
|
throw new TypeError("Local draft requires draftId and title");
|
||||||
|
}
|
||||||
|
if (title.length > 160 || values.body.length > 100_000) {
|
||||||
|
throw new TypeError("Local draft exceeds bounded field limits");
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
draftId,
|
||||||
|
title,
|
||||||
|
body: values.body,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -19,8 +19,10 @@ augmentation으로 `"reference-feature": ReferenceFeatureInput`을 기여하므
|
|||||||
별도 cast나 runtime shape 확인 없이 정확한 input type을 받는다. 다만 동적 호출로
|
별도 cast나 runtime shape 확인 없이 정확한 input type을 받는다. 다만 동적 호출로
|
||||||
설치되지 않은 ID가 들어오는 경우를 위해 `get`의 runtime guard도 유지한다.
|
설치되지 않은 ID가 들어오는 경우를 위해 `get`의 runtime guard도 유지한다.
|
||||||
|
|
||||||
예측 가능한 실패는 `src/application/result.ts`의 공통
|
예측 가능한 실패는 canonical shared contract인
|
||||||
`Result<Value, Failure = AppFailure>`로 반환한다. `AppFailure.kind`는
|
`src/contracts/result.ts`의 `Result<Value, Failure = AppFailure>`로 반환한다.
|
||||||
|
`src/application/result.ts`는 기존 호출자를 위한 deprecated migration shim일
|
||||||
|
뿐 새 코드의 import 경로가 아니다. `AppFailure.kind`는
|
||||||
`ERROR_REGISTRY` key에서 파생된 닫힌 vocabulary이며, transport 호환 이름인
|
`ERROR_REGISTRY` key에서 파생된 닫힌 vocabulary이며, transport 호환 이름인
|
||||||
`ApiFailure`는 같은 type의 alias다.
|
`ApiFailure`는 같은 type의 alias다.
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import {
|
|||||||
createFeatureHttpBinding,
|
createFeatureHttpBinding,
|
||||||
type InstalledHttpOperationExecutor,
|
type InstalledHttpOperationExecutor,
|
||||||
} from "../../../adapters/http/index.ts";
|
} from "../../../adapters/http/index.ts";
|
||||||
|
import {
|
||||||
|
defineFeatureAdapterContribution,
|
||||||
|
} from "../../feature-adapter-contribution.ts";
|
||||||
import { createReferenceFeatureInput } from "../application/reference-feature-api.ts";
|
import { createReferenceFeatureInput } from "../application/reference-feature-api.ts";
|
||||||
import {
|
import {
|
||||||
REFERENCE_FEATURE_ID,
|
REFERENCE_FEATURE_ID,
|
||||||
@@ -17,15 +20,14 @@ export type InstalledContractOperationExecutor =
|
|||||||
/**
|
/**
|
||||||
* Feature-owned composition seam.
|
* Feature-owned composition seam.
|
||||||
*
|
*
|
||||||
* The feature contributes typed operation descriptors and its application
|
* The feature receives only the HTTP capability it declares in `needs`.
|
||||||
* gateway. HTTP lifecycle/error normalization stays in the reusable capability
|
* HTTP lifecycle/error normalization stays in the reusable capability binding.
|
||||||
* binding rather than being repeated by every product feature.
|
|
||||||
*/
|
*/
|
||||||
export function createReferenceFeatureInstalledInput(context: Readonly<{
|
export function createReferenceFeatureInstalledInput(context: Readonly<{
|
||||||
contractOperations: InstalledHttpOperationExecutor;
|
http: InstalledHttpOperationExecutor;
|
||||||
}>) {
|
}>) {
|
||||||
const http = createFeatureHttpBinding(
|
const http = createFeatureHttpBinding(
|
||||||
context.contractOperations,
|
context.http,
|
||||||
REFERENCE_HTTP_OPERATIONS,
|
REFERENCE_HTTP_OPERATIONS,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -35,7 +37,9 @@ export function createReferenceFeatureInstalledInput(context: Readonly<{
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const REFERENCE_FEATURE_ADAPTER_CONTRIBUTION = Object.freeze({
|
export const REFERENCE_FEATURE_ADAPTER_CONTRIBUTION =
|
||||||
|
defineFeatureAdapterContribution({
|
||||||
featureId: REFERENCE_FEATURE_ID,
|
featureId: REFERENCE_FEATURE_ID,
|
||||||
|
needs: ["http"] as const,
|
||||||
createInput: createReferenceFeatureInstalledInput,
|
createInput: createReferenceFeatureInstalledInput,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,40 +1,57 @@
|
|||||||
import {
|
import {
|
||||||
defineFeatureHttpOperation,
|
defineFeatureHttpOperationForRoutes,
|
||||||
type FeatureHttpBinding,
|
type FeatureHttpBinding,
|
||||||
} from "../../../adapters/http/index.ts";
|
} from "../../../adapters/http/index.ts";
|
||||||
import type {
|
import {
|
||||||
ReferenceCreateCommand,
|
CREATE_REFERENCE_RESOURCE_CONTRACT,
|
||||||
ReferenceGateway,
|
GET_REFERENCE_RESOURCE_CONTRACT,
|
||||||
ReferenceListFilters,
|
LIST_REFERENCE_RESOURCES_CONTRACT,
|
||||||
} from "../application/reference-feature-api.ts";
|
} from "../contracts/reference-feature-contract-contribution.ts";
|
||||||
import type { ReferenceResource } from "../domain/reference-resource.ts";
|
import {
|
||||||
|
REFERENCE_FEATURE_CONTRACT,
|
||||||
|
} from "../contracts/reference-feature-contract.ts";
|
||||||
import {
|
import {
|
||||||
mapReferenceResourceListPayload,
|
mapReferenceResourceListPayload,
|
||||||
mapReferenceResourcePayload,
|
mapReferenceResourcePayload,
|
||||||
} from "../contracts/reference-mapper.ts";
|
} from "../contracts/reference-mapper.ts";
|
||||||
|
import type {
|
||||||
|
ReferenceGateway,
|
||||||
|
} from "../application/reference-feature-api.ts";
|
||||||
|
|
||||||
|
export type ReferenceFeatureRouteId =
|
||||||
|
keyof typeof REFERENCE_FEATURE_CONTRACT.routes;
|
||||||
|
|
||||||
|
const defineReferenceHttpOperation =
|
||||||
|
defineFeatureHttpOperationForRoutes<ReferenceFeatureRouteId>();
|
||||||
|
|
||||||
export const REFERENCE_HTTP_OPERATIONS = Object.freeze({
|
export const REFERENCE_HTTP_OPERATIONS = Object.freeze({
|
||||||
LIST_REFERENCE_RESOURCES: defineFeatureHttpOperation<
|
LIST_REFERENCE_RESOURCES: defineReferenceHttpOperation({
|
||||||
ReferenceListFilters,
|
contract: LIST_REFERENCE_RESOURCES_CONTRACT,
|
||||||
readonly ReferenceResource[]
|
|
||||||
>({
|
|
||||||
operationId: "LIST_REFERENCE_RESOURCES",
|
|
||||||
routeId: "REFERENCE_RESOURCE_LIST",
|
routeId: "REFERENCE_RESOURCE_LIST",
|
||||||
mapSuccess: mapReferenceResourceListPayload,
|
mapSuccess: mapReferenceResourceListPayload,
|
||||||
}),
|
}),
|
||||||
CREATE_REFERENCE_RESOURCE: defineFeatureHttpOperation<
|
CREATE_REFERENCE_RESOURCE: defineReferenceHttpOperation({
|
||||||
ReferenceCreateCommand,
|
contract: CREATE_REFERENCE_RESOURCE_CONTRACT,
|
||||||
ReferenceResource
|
|
||||||
>({
|
|
||||||
operationId: "CREATE_REFERENCE_RESOURCE",
|
|
||||||
routeId: "REFERENCE_RESOURCE_LIST",
|
routeId: "REFERENCE_RESOURCE_LIST",
|
||||||
mapSuccess: mapReferenceResourcePayload,
|
mapSuccess: mapReferenceResourcePayload,
|
||||||
|
mapProblem(problem, metadata) {
|
||||||
|
if (metadata.status === 409) {
|
||||||
|
return Object.freeze({
|
||||||
|
kind: "CONFLICT" as const,
|
||||||
|
code: problem.code ?? "REFERENCE_RESOURCE_CONFLICT",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (metadata.status === 422) {
|
||||||
|
return Object.freeze({
|
||||||
|
kind: "VALIDATION_REJECTED" as const,
|
||||||
|
code: problem.code ?? "REFERENCE_RESOURCE_REJECTED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
GET_REFERENCE_RESOURCE: defineFeatureHttpOperation<
|
GET_REFERENCE_RESOURCE: defineReferenceHttpOperation({
|
||||||
Readonly<{ resourceId: string }>,
|
contract: GET_REFERENCE_RESOURCE_CONTRACT,
|
||||||
ReferenceResource
|
|
||||||
>({
|
|
||||||
operationId: "GET_REFERENCE_RESOURCE",
|
|
||||||
routeId: "REFERENCE_RESOURCE_DETAIL",
|
routeId: "REFERENCE_RESOURCE_DETAIL",
|
||||||
mapSuccess: mapReferenceResourcePayload,
|
mapSuccess: mapReferenceResourcePayload,
|
||||||
}),
|
}),
|
||||||
|
|||||||
+42
-56
@@ -2,17 +2,22 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
CommandEffectDescriptor,
|
CommandEffectDescriptor,
|
||||||
HttpRequestProjection,
|
|
||||||
InstalledContractContribution,
|
InstalledContractContribution,
|
||||||
InstalledHttpContract,
|
InstalledHttpContract,
|
||||||
RuntimeValidator,
|
RuntimeValidator,
|
||||||
} from "../../../contracts/external-contract-runtime.ts";
|
} from "../../../contracts/external-contract-runtime.ts";
|
||||||
import { REFERENCE_FEATURE_ID } from "./reference-feature-contract.ts";
|
import { REFERENCE_FEATURE_ID } from "./reference-feature-contract.ts";
|
||||||
import {
|
import {
|
||||||
|
createReferenceResourceCommandSchema,
|
||||||
|
referenceProblemSchema,
|
||||||
|
referenceResourceDtoSchema,
|
||||||
referenceResourceListQuerySchema,
|
referenceResourceListQuerySchema,
|
||||||
referenceResourceParamsSchema,
|
referenceResourceParamsSchema,
|
||||||
|
type ReferenceProblem,
|
||||||
} from "./reference-schemas.ts";
|
} from "./reference-schemas.ts";
|
||||||
|
|
||||||
|
export type { ReferenceProblem } from "./reference-schemas.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* §4.8. The single `TEMPLATE_FIXTURE` contribution. It keeps the reference
|
* §4.8. The single `TEMPLATE_FIXTURE` contribution. It keeps the reference
|
||||||
* HTTP vertical executable as deterministic template data and is excluded from
|
* HTTP vertical executable as deterministic template data and is excluded from
|
||||||
@@ -53,33 +58,10 @@ function zodValidator<T>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const referenceResourceDto = z
|
const PROBLEM_VALIDATOR = zodValidator(
|
||||||
.object({
|
"ReferenceProblem",
|
||||||
id: z.string().min(1).max(120),
|
referenceProblemSchema,
|
||||||
name: z.string().min(1).max(240),
|
);
|
||||||
createdAt: z.string().min(1).optional(),
|
|
||||||
})
|
|
||||||
.strip();
|
|
||||||
|
|
||||||
const problemSchema = z
|
|
||||||
.object({
|
|
||||||
type: z.string().min(1).max(512),
|
|
||||||
title: z.string().min(1).max(240),
|
|
||||||
status: z.int().min(100).max(599),
|
|
||||||
code: z.string().min(1).max(120).optional(),
|
|
||||||
})
|
|
||||||
.strip();
|
|
||||||
|
|
||||||
export type ReferenceProblem = z.output<typeof problemSchema>;
|
|
||||||
|
|
||||||
const PROBLEM_VALIDATOR = zodValidator("ReferenceProblem", problemSchema);
|
|
||||||
|
|
||||||
const createCommandSchema = z
|
|
||||||
.object({
|
|
||||||
name: z.string().trim().min(1).max(120),
|
|
||||||
note: z.string().trim().max(500).optional(),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fixture-side classifier standing in for the package-provided pure bounded
|
* Fixture-side classifier standing in for the package-provided pure bounded
|
||||||
@@ -87,17 +69,17 @@ const createCommandSchema = z
|
|||||||
*/
|
*/
|
||||||
const CREATE_EFFECT: CommandEffectDescriptor<ReferenceProblem> = Object.freeze({
|
const CREATE_EFFECT: CommandEffectDescriptor<ReferenceProblem> = Object.freeze({
|
||||||
successEffect: "APPLIED_CONFIRMED" as const,
|
successEffect: "APPLIED_CONFIRMED" as const,
|
||||||
classifyProblem({ status }: Readonly<{ status: number; problem: ReferenceProblem }>) {
|
classifyProblem({
|
||||||
if (status === 400 || status === 409 || status === 422) return "NOT_APPLIED";
|
status,
|
||||||
|
}: Readonly<{ status: number; problem: ReferenceProblem }>) {
|
||||||
|
if (status === 400 || status === 409 || status === 422) {
|
||||||
|
return "NOT_APPLIED";
|
||||||
|
}
|
||||||
return "MAYBE_APPLIED";
|
return "MAYBE_APPLIED";
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const LIST_REFERENCE_RESOURCES: InstalledHttpContract<
|
export const LIST_REFERENCE_RESOURCES_CONTRACT = Object.freeze({
|
||||||
z.output<typeof referenceResourceListQuerySchema>,
|
|
||||||
readonly z.output<typeof referenceResourceDto>[],
|
|
||||||
ReferenceProblem
|
|
||||||
> = Object.freeze({
|
|
||||||
contract: Object.freeze({
|
contract: Object.freeze({
|
||||||
operationId: "LIST_REFERENCE_RESOURCES",
|
operationId: "LIST_REFERENCE_RESOURCES",
|
||||||
method: "GET" as const,
|
method: "GET" as const,
|
||||||
@@ -108,7 +90,7 @@ const LIST_REFERENCE_RESOURCES: InstalledHttpContract<
|
|||||||
),
|
),
|
||||||
outputValidator: zodValidator(
|
outputValidator: zodValidator(
|
||||||
"ReferenceResourceListPayload",
|
"ReferenceResourceListPayload",
|
||||||
z.array(referenceResourceDto).max(100),
|
z.array(referenceResourceDtoSchema).max(100),
|
||||||
),
|
),
|
||||||
problemValidator: PROBLEM_VALIDATOR,
|
problemValidator: PROBLEM_VALIDATOR,
|
||||||
acceptedStatuses: Object.freeze([200]),
|
acceptedStatuses: Object.freeze([200]),
|
||||||
@@ -139,13 +121,13 @@ const LIST_REFERENCE_RESOURCES: InstalledHttpContract<
|
|||||||
authProfileId: "REFERENCE_EXTERNAL_BEARER",
|
authProfileId: "REFERENCE_EXTERNAL_BEARER",
|
||||||
diagnosticsOperation: "reference.list",
|
diagnosticsOperation: "reference.list",
|
||||||
}),
|
}),
|
||||||
});
|
}) satisfies InstalledHttpContract<
|
||||||
|
z.output<typeof referenceResourceListQuerySchema>,
|
||||||
const GET_REFERENCE_RESOURCE: InstalledHttpContract<
|
readonly z.output<typeof referenceResourceDtoSchema>[],
|
||||||
z.output<typeof referenceResourceParamsSchema>,
|
|
||||||
z.output<typeof referenceResourceDto>,
|
|
||||||
ReferenceProblem
|
ReferenceProblem
|
||||||
> = Object.freeze({
|
>;
|
||||||
|
|
||||||
|
export const GET_REFERENCE_RESOURCE_CONTRACT = Object.freeze({
|
||||||
contract: Object.freeze({
|
contract: Object.freeze({
|
||||||
operationId: "GET_REFERENCE_RESOURCE",
|
operationId: "GET_REFERENCE_RESOURCE",
|
||||||
method: "GET" as const,
|
method: "GET" as const,
|
||||||
@@ -156,7 +138,7 @@ const GET_REFERENCE_RESOURCE: InstalledHttpContract<
|
|||||||
),
|
),
|
||||||
outputValidator: zodValidator(
|
outputValidator: zodValidator(
|
||||||
"ReferenceResourcePayload",
|
"ReferenceResourcePayload",
|
||||||
referenceResourceDto,
|
referenceResourceDtoSchema,
|
||||||
),
|
),
|
||||||
problemValidator: PROBLEM_VALIDATOR,
|
problemValidator: PROBLEM_VALIDATOR,
|
||||||
acceptedStatuses: Object.freeze([200]),
|
acceptedStatuses: Object.freeze([200]),
|
||||||
@@ -183,24 +165,24 @@ const GET_REFERENCE_RESOURCE: InstalledHttpContract<
|
|||||||
authProfileId: "REFERENCE_EXTERNAL_BEARER",
|
authProfileId: "REFERENCE_EXTERNAL_BEARER",
|
||||||
diagnosticsOperation: "reference.detail",
|
diagnosticsOperation: "reference.detail",
|
||||||
}),
|
}),
|
||||||
});
|
}) satisfies InstalledHttpContract<
|
||||||
|
z.output<typeof referenceResourceParamsSchema>,
|
||||||
const CREATE_REFERENCE_RESOURCE: InstalledHttpContract<
|
z.output<typeof referenceResourceDtoSchema>,
|
||||||
z.output<typeof createCommandSchema>,
|
|
||||||
z.output<typeof referenceResourceDto>,
|
|
||||||
ReferenceProblem
|
ReferenceProblem
|
||||||
> = Object.freeze({
|
>;
|
||||||
|
|
||||||
|
export const CREATE_REFERENCE_RESOURCE_CONTRACT = Object.freeze({
|
||||||
contract: Object.freeze({
|
contract: Object.freeze({
|
||||||
operationId: "CREATE_REFERENCE_RESOURCE",
|
operationId: "CREATE_REFERENCE_RESOURCE",
|
||||||
method: "POST" as const,
|
method: "POST" as const,
|
||||||
pathTemplate: "/api/reference-resources",
|
pathTemplate: "/api/reference-resources",
|
||||||
inputValidator: zodValidator(
|
inputValidator: zodValidator(
|
||||||
"CreateReferenceResourceCommand",
|
"CreateReferenceResourceCommand",
|
||||||
createCommandSchema,
|
createReferenceResourceCommandSchema,
|
||||||
),
|
),
|
||||||
outputValidator: zodValidator(
|
outputValidator: zodValidator(
|
||||||
"ReferenceResourcePayload",
|
"ReferenceResourcePayload",
|
||||||
referenceResourceDto,
|
referenceResourceDtoSchema,
|
||||||
),
|
),
|
||||||
problemValidator: PROBLEM_VALIDATOR,
|
problemValidator: PROBLEM_VALIDATOR,
|
||||||
acceptedStatuses: Object.freeze([200, 201]),
|
acceptedStatuses: Object.freeze([200, 201]),
|
||||||
@@ -213,7 +195,7 @@ const CREATE_REFERENCE_RESOURCE: InstalledHttpContract<
|
|||||||
operationIdentityField: "idempotencyKey",
|
operationIdentityField: "idempotencyKey",
|
||||||
}),
|
}),
|
||||||
commandEffect: CREATE_EFFECT,
|
commandEffect: CREATE_EFFECT,
|
||||||
projectRequest(input: z.output<typeof createCommandSchema>) {
|
projectRequest(input: z.output<typeof createReferenceResourceCommandSchema>) {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
pathValues: Object.freeze({}),
|
pathValues: Object.freeze({}),
|
||||||
queryEntries: Object.freeze([]),
|
queryEntries: Object.freeze([]),
|
||||||
@@ -235,7 +217,11 @@ const CREATE_REFERENCE_RESOURCE: InstalledHttpContract<
|
|||||||
authProfileId: "REFERENCE_EXTERNAL_BEARER",
|
authProfileId: "REFERENCE_EXTERNAL_BEARER",
|
||||||
diagnosticsOperation: "reference.create",
|
diagnosticsOperation: "reference.create",
|
||||||
}),
|
}),
|
||||||
});
|
}) satisfies InstalledHttpContract<
|
||||||
|
z.output<typeof createReferenceResourceCommandSchema>,
|
||||||
|
z.output<typeof referenceResourceDtoSchema>,
|
||||||
|
ReferenceProblem
|
||||||
|
>;
|
||||||
|
|
||||||
export const REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION: InstalledContractContribution =
|
export const REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION: InstalledContractContribution =
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
@@ -247,9 +233,9 @@ export const REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION: InstalledContractContribut
|
|||||||
revision: 1 as const,
|
revision: 1 as const,
|
||||||
}),
|
}),
|
||||||
http: Object.freeze([
|
http: Object.freeze([
|
||||||
LIST_REFERENCE_RESOURCES,
|
LIST_REFERENCE_RESOURCES_CONTRACT,
|
||||||
GET_REFERENCE_RESOURCE,
|
GET_REFERENCE_RESOURCE_CONTRACT,
|
||||||
CREATE_REFERENCE_RESOURCE,
|
CREATE_REFERENCE_RESOURCE_CONTRACT,
|
||||||
]) as readonly InstalledHttpContract<unknown, unknown, unknown>[],
|
]) as readonly InstalledHttpContract<unknown, unknown, unknown>[],
|
||||||
events: Object.freeze([]),
|
events: Object.freeze([]),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type InstalledBoundaryMapper,
|
type InstalledBoundaryMapper,
|
||||||
type MappingResult,
|
type MappingResult,
|
||||||
} from "../../../contracts/boundary-mapper.ts";
|
} from "../../../contracts/boundary-mapper.ts";
|
||||||
|
import type { ReferenceResourceDto } from "./reference-schemas.ts";
|
||||||
|
|
||||||
export type ReferenceResourceView = Readonly<{
|
export type ReferenceResourceView = Readonly<{
|
||||||
resourceId: string;
|
resourceId: string;
|
||||||
@@ -16,22 +17,19 @@ export type ReferenceResourceView = Readonly<{
|
|||||||
optimistic?: boolean;
|
optimistic?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operation-specific mapper. The HTTP contract has already validated the wire
|
||||||
|
* shape, so this mapper owns only wire -> domain construction.
|
||||||
|
*/
|
||||||
export function mapReferenceResourcePayload(
|
export function mapReferenceResourcePayload(
|
||||||
value: unknown,
|
dto: ReferenceResourceDto,
|
||||||
): MappingResult<ReferenceResource> {
|
): MappingResult<ReferenceResource> {
|
||||||
if (!value || typeof value !== "object") {
|
|
||||||
return mappingFailure("MAPPING_INVARIANT_REJECTED");
|
|
||||||
}
|
|
||||||
const dto = value as Record<string, unknown>;
|
|
||||||
if (typeof dto.id !== "string" || typeof dto.name !== "string") {
|
|
||||||
return mappingFailure("MAPPING_INVARIANT_REJECTED");
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
return mappingSuccess(
|
return mappingSuccess(
|
||||||
createReferenceResource({
|
createReferenceResource({
|
||||||
id: dto.id,
|
id: dto.id,
|
||||||
displayName: dto.name,
|
displayName: dto.name,
|
||||||
createdAt: typeof dto.createdAt === "string" ? dto.createdAt : null,
|
createdAt: dto.createdAt ?? null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -40,14 +38,8 @@ export function mapReferenceResourcePayload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function mapReferenceResourceListPayload(
|
export function mapReferenceResourceListPayload(
|
||||||
payload: unknown,
|
payload: readonly ReferenceResourceDto[],
|
||||||
): MappingResult<readonly ReferenceResource[]> {
|
): MappingResult<readonly ReferenceResource[]> {
|
||||||
if (!Array.isArray(payload)) {
|
|
||||||
return mappingFailure("MAPPING_INVARIANT_REJECTED");
|
|
||||||
}
|
|
||||||
if (payload.length > 100) {
|
|
||||||
return mappingFailure("OUTPUT_LIMIT_EXCEEDED");
|
|
||||||
}
|
|
||||||
const output: ReferenceResource[] = [];
|
const output: ReferenceResource[] = [];
|
||||||
for (const item of payload) {
|
for (const item of payload) {
|
||||||
const mapped = mapReferenceResourcePayload(item);
|
const mapped = mapReferenceResourcePayload(item);
|
||||||
@@ -57,22 +49,58 @@ export function mapReferenceResourceListPayload(
|
|||||||
return mappingSuccess(Object.freeze(output));
|
return mappingSuccess(Object.freeze(output));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isReferenceResourceDto(value: unknown): value is ReferenceResourceDto {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||||
|
const dto = value as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof dto.id === "string" &&
|
||||||
|
typeof dto.name === "string" &&
|
||||||
|
(dto.createdAt === undefined || typeof dto.createdAt === "string")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapUnknownReferenceResourcePayload(
|
||||||
|
value: unknown,
|
||||||
|
): MappingResult<ReferenceResource> {
|
||||||
|
return isReferenceResourceDto(value)
|
||||||
|
? mapReferenceResourcePayload(value)
|
||||||
|
: mappingFailure("MAPPING_INVARIANT_REJECTED");
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapUnknownReferenceResourceListPayload(
|
||||||
|
value: unknown,
|
||||||
|
): MappingResult<readonly ReferenceResource[]> {
|
||||||
|
if (
|
||||||
|
!Array.isArray(value) ||
|
||||||
|
value.length > 100 ||
|
||||||
|
!value.every(isReferenceResourceDto)
|
||||||
|
) {
|
||||||
|
return mappingFailure(
|
||||||
|
Array.isArray(value) && value.length > 100
|
||||||
|
? "OUTPUT_LIMIT_EXCEEDED"
|
||||||
|
: "MAPPING_INVARIANT_REJECTED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return mapReferenceResourceListPayload(value);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registry-facing compatibility projection. Feature adapters should prefer the
|
* Registry-facing compatibility projection. The registry is intentionally
|
||||||
* operation-specific mapper functions above so their result type is exact.
|
* untyped, so its adapter performs the defensive shape check. Feature HTTP
|
||||||
|
* bindings use the typed operation-specific mappers above.
|
||||||
*/
|
*/
|
||||||
export function mapReferenceOperation(
|
export function mapReferenceOperation(
|
||||||
operationId: string,
|
operationId: string,
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
): MappingResult<ReferenceResource | readonly ReferenceResource[]> {
|
): MappingResult<ReferenceResource | readonly ReferenceResource[]> {
|
||||||
if (operationId === "LIST_REFERENCE_RESOURCES") {
|
if (operationId === "LIST_REFERENCE_RESOURCES") {
|
||||||
return mapReferenceResourceListPayload(payload);
|
return mapUnknownReferenceResourceListPayload(payload);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
operationId === "CREATE_REFERENCE_RESOURCE" ||
|
operationId === "CREATE_REFERENCE_RESOURCE" ||
|
||||||
operationId === "GET_REFERENCE_RESOURCE"
|
operationId === "GET_REFERENCE_RESOURCE"
|
||||||
) {
|
) {
|
||||||
return mapReferenceResourcePayload(payload);
|
return mapUnknownReferenceResourcePayload(payload);
|
||||||
}
|
}
|
||||||
return mappingFailure("MAPPING_INVARIANT_REJECTED");
|
return mappingFailure("MAPPING_INVARIANT_REJECTED");
|
||||||
}
|
}
|
||||||
@@ -85,7 +113,7 @@ export const REFERENCE_BOUNDARY_MAPPERS = Object.freeze({
|
|||||||
outputContractId: "ReferenceResourceList",
|
outputContractId: "ReferenceResourceList",
|
||||||
owner: "feature-frontend-reference-feature-vertical-slice",
|
owner: "feature-frontend-reference-feature-vertical-slice",
|
||||||
maxOutputItems: 100,
|
maxOutputItems: 100,
|
||||||
map: mapReferenceResourceListPayload,
|
map: mapUnknownReferenceResourceListPayload,
|
||||||
}),
|
}),
|
||||||
ReferenceResourceMapper: Object.freeze({
|
ReferenceResourceMapper: Object.freeze({
|
||||||
mapperId: "ReferenceResourceMapper",
|
mapperId: "ReferenceResourceMapper",
|
||||||
@@ -94,7 +122,7 @@ export const REFERENCE_BOUNDARY_MAPPERS = Object.freeze({
|
|||||||
outputContractId: "ReferenceResource",
|
outputContractId: "ReferenceResource",
|
||||||
owner: "feature-frontend-reference-feature-vertical-slice",
|
owner: "feature-frontend-reference-feature-vertical-slice",
|
||||||
maxOutputItems: 1,
|
maxOutputItems: 1,
|
||||||
map: mapReferenceResourcePayload,
|
map: mapUnknownReferenceResourcePayload,
|
||||||
}),
|
}),
|
||||||
} satisfies Readonly<Record<string, InstalledBoundaryMapper>>);
|
} satisfies Readonly<Record<string, InstalledBoundaryMapper>>);
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const referenceResourceListQuerySchema = z
|
|||||||
: Array.isArray(value)
|
: Array.isArray(value)
|
||||||
? value
|
? value
|
||||||
: [value],
|
: [value],
|
||||||
z.array(z.string().trim().min(1)),
|
z.array(z.string().trim().min(1)).readonly(),
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
@@ -25,7 +25,7 @@ export const referenceResourceParamsSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const referenceResourceDtoSchema = z
|
export const referenceResourceDtoSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: z.string().min(1).max(120),
|
id: z.string().min(1).max(120),
|
||||||
name: z.string().min(1).max(240),
|
name: z.string().min(1).max(240),
|
||||||
@@ -33,6 +33,28 @@ const referenceResourceDtoSchema = z
|
|||||||
})
|
})
|
||||||
.strip();
|
.strip();
|
||||||
|
|
||||||
|
export type ReferenceResourceDto = z.output<
|
||||||
|
typeof referenceResourceDtoSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const referenceProblemSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.string().min(1).max(512),
|
||||||
|
title: z.string().min(1).max(240),
|
||||||
|
status: z.int().min(100).max(599),
|
||||||
|
code: z.string().min(1).max(120).optional(),
|
||||||
|
})
|
||||||
|
.strip();
|
||||||
|
|
||||||
|
export type ReferenceProblem = z.output<typeof referenceProblemSchema>;
|
||||||
|
|
||||||
|
export const createReferenceResourceCommandSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().trim().min(1).max(120),
|
||||||
|
note: z.string().trim().max(500).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
const payloadSchemas = {
|
const payloadSchemas = {
|
||||||
ReferenceResourceListPayload: z.array(referenceResourceDtoSchema).max(100),
|
ReferenceResourceListPayload: z.array(referenceResourceDtoSchema).max(100),
|
||||||
ReferenceResourcePayload: referenceResourceDtoSchema,
|
ReferenceResourcePayload: referenceResourceDtoSchema,
|
||||||
@@ -42,12 +64,7 @@ const requestSchemas = {
|
|||||||
ReferenceResourceParams: referenceResourceParamsSchema,
|
ReferenceResourceParams: referenceResourceParamsSchema,
|
||||||
ReferenceResourceListQuery: referenceResourceListQuerySchema,
|
ReferenceResourceListQuery: referenceResourceListQuerySchema,
|
||||||
NoRequest: z.object({}).strict(),
|
NoRequest: z.object({}).strict(),
|
||||||
CreateReferenceResourceCommand: z
|
CreateReferenceResourceCommand: createReferenceResourceCommandSchema,
|
||||||
.object({
|
|
||||||
name: z.string().trim().min(1).max(120),
|
|
||||||
note: z.string().trim().max(500).optional(),
|
|
||||||
})
|
|
||||||
.strict(),
|
|
||||||
} satisfies Record<string, z.ZodType>;
|
} satisfies Record<string, z.ZodType>;
|
||||||
|
|
||||||
function project(result: z.ZodSafeParseResult<unknown>) {
|
function project(result: z.ZodSafeParseResult<unknown>) {
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ function databaseName(_prefix: string): string {
|
|||||||
return `ca-idb-v1:a${token.slice(0, 31)}.n${token.slice(1)}.p${token.split("").reverse().join("").slice(0, 31)}`;
|
return `ca-idb-v1:a${token.slice(0, 31)}.n${token.slice(1)}.p${token.split("").reverse().join("").slice(0, 31)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installLifecycleRuntime(
|
async function startLifecycleRuntime(
|
||||||
page: Page,
|
page: Page,
|
||||||
name: string,
|
name: string,
|
||||||
targetVersion: 1 | 2,
|
targetVersion: 1 | 2,
|
||||||
blockedTimeoutMs = 1_000,
|
blockedTimeoutMs = 1_000,
|
||||||
) {
|
): Promise<void> {
|
||||||
return await page.evaluate(
|
await page.evaluate(
|
||||||
async ({
|
async ({
|
||||||
currentDatabaseName,
|
currentDatabaseName,
|
||||||
currentTargetVersion,
|
currentTargetVersion,
|
||||||
@@ -173,8 +173,12 @@ async function installLifecycleRuntime(
|
|||||||
__indexedDbLifecycleRuntime?: unknown;
|
__indexedDbLifecycleRuntime?: unknown;
|
||||||
}
|
}
|
||||||
).__indexedDbLifecycleRuntime = runtime;
|
).__indexedDbLifecycleRuntime = runtime;
|
||||||
const opened = await runtime.open();
|
const opening = runtime.open();
|
||||||
return { opened, status: runtime.getStatus() };
|
(
|
||||||
|
globalThis as unknown as {
|
||||||
|
__indexedDbLifecycleOpening?: Promise<unknown>;
|
||||||
|
}
|
||||||
|
).__indexedDbLifecycleOpening = opening;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
currentDatabaseName: name,
|
currentDatabaseName: name,
|
||||||
@@ -184,6 +188,35 @@ async function installLifecycleRuntime(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function settleLifecycleRuntime(page: Page): Promise<unknown> {
|
||||||
|
return await page.evaluate(async () => {
|
||||||
|
const state = globalThis as unknown as {
|
||||||
|
__indexedDbLifecycleRuntime?: {
|
||||||
|
getStatus(): unknown;
|
||||||
|
};
|
||||||
|
__indexedDbLifecycleOpening?: Promise<unknown>;
|
||||||
|
};
|
||||||
|
if (!state.__indexedDbLifecycleRuntime || !state.__indexedDbLifecycleOpening) {
|
||||||
|
throw new Error("IndexedDB lifecycle runtime is not installed.");
|
||||||
|
}
|
||||||
|
const opened = await state.__indexedDbLifecycleOpening;
|
||||||
|
return {
|
||||||
|
opened,
|
||||||
|
status: state.__indexedDbLifecycleRuntime.getStatus(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installLifecycleRuntime(
|
||||||
|
page: Page,
|
||||||
|
name: string,
|
||||||
|
targetVersion: 1 | 2,
|
||||||
|
blockedTimeoutMs = 1_000,
|
||||||
|
): Promise<unknown> {
|
||||||
|
await startLifecycleRuntime(page, name, targetVersion, blockedTimeoutMs);
|
||||||
|
return await settleLifecycleRuntime(page);
|
||||||
|
}
|
||||||
|
|
||||||
async function lifecycleStatus(page: Page): Promise<unknown> {
|
async function lifecycleStatus(page: Page): Promise<unknown> {
|
||||||
return await page.evaluate(() => {
|
return await page.evaluate(() => {
|
||||||
const runtime = (
|
const runtime = (
|
||||||
@@ -977,12 +1010,16 @@ test("fails a blocked v2 upgrade closed, then closes its late connection after t
|
|||||||
);
|
);
|
||||||
expect(blocker).toBe("OPEN");
|
expect(blocker).toBe("OPEN");
|
||||||
|
|
||||||
const blocked = await installLifecycleRuntime(
|
await startLifecycleRuntime(page, name, 2, 500);
|
||||||
page,
|
await expect
|
||||||
name,
|
.poll(async () => await lifecycleStatus(page))
|
||||||
2,
|
.toEqual({
|
||||||
25,
|
kind: "BLOCKED",
|
||||||
);
|
currentVersion: 1,
|
||||||
|
targetVersion: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const blocked = await settleLifecycleRuntime(page);
|
||||||
expect(blocked).toMatchObject({
|
expect(blocked).toMatchObject({
|
||||||
opened: {
|
opened: {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -992,9 +1029,8 @@ test("fails a blocked v2 upgrade closed, then closes its late connection after t
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
kind: "BLOCKED",
|
kind: "CLOSED",
|
||||||
currentVersion: 1,
|
reason: "NOT_OPENED",
|
||||||
targetVersion: 2,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import type {
|
||||||
|
IndexedDbRuntimeDependencies,
|
||||||
|
} from "../../src/adapters/storage/indexeddb/indexeddb-types.ts";
|
||||||
|
import type { LocalDraft } from "../../src/features/local-draft-feature/domain/local-draft.ts";
|
||||||
|
import {
|
||||||
|
expect,
|
||||||
|
test,
|
||||||
|
} from "../support/browser/strict-browser-test.ts";
|
||||||
|
|
||||||
|
test("composes the local-draft feature over the real browser IndexedDB runtime", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.route("**/favicon.ico", (route) =>
|
||||||
|
route.fulfill({ status: 204 }),
|
||||||
|
);
|
||||||
|
await page.goto("/config.json");
|
||||||
|
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const indexedDbModulePath =
|
||||||
|
"/src/adapters/storage/indexeddb/index.ts";
|
||||||
|
const contributionModulePath =
|
||||||
|
"/src/features/feature-adapter-contribution.ts";
|
||||||
|
const localDraftModulePath =
|
||||||
|
"/src/features/local-draft-feature/adapters/create-local-draft-feature-input.ts";
|
||||||
|
|
||||||
|
const {
|
||||||
|
createIndexedDbRuntime,
|
||||||
|
indexedDbPhysicalDatabaseName,
|
||||||
|
} = (await import(
|
||||||
|
/* @vite-ignore */ indexedDbModulePath
|
||||||
|
)) as typeof import("../../src/adapters/storage/indexeddb/index.ts");
|
||||||
|
const {
|
||||||
|
composeFeatureAdapterInputs,
|
||||||
|
createIndexedDbRepositoryProvider,
|
||||||
|
} = (await import(
|
||||||
|
/* @vite-ignore */ contributionModulePath
|
||||||
|
)) as typeof import("../../src/features/feature-adapter-contribution.ts");
|
||||||
|
const {
|
||||||
|
LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION,
|
||||||
|
} = (await import(
|
||||||
|
/* @vite-ignore */ localDraftModulePath
|
||||||
|
)) as typeof import("../../src/features/local-draft-feature/adapters/create-local-draft-feature-input.ts");
|
||||||
|
|
||||||
|
const suffix = crypto.randomUUID().replaceAll("-", "");
|
||||||
|
const scope = Object.freeze({
|
||||||
|
authorityToken: `a${suffix.slice(0, 31)}`,
|
||||||
|
namespaceToken: `n${suffix.slice(1, 32)}`,
|
||||||
|
partitionToken: `p${[...suffix].reverse().join("").slice(0, 31)}`,
|
||||||
|
accountScope: "ORIGIN_SHARED" as const,
|
||||||
|
});
|
||||||
|
const storagePolicy = Object.freeze({
|
||||||
|
owner: "local-draft-feature",
|
||||||
|
namespace: "local-draft",
|
||||||
|
classification: "INTERNAL" as const,
|
||||||
|
authority: "LOCAL_FIRST" as const,
|
||||||
|
accountScope: "ORIGIN_SHARED" as const,
|
||||||
|
retention: Object.freeze({ kind: "EXPLICIT_DELETE" as const }),
|
||||||
|
softBudgetBytes: 1_000_000,
|
||||||
|
hardBudgetBytes: 2_000_000,
|
||||||
|
evictionPriority: "USER_AUTHORED" as const,
|
||||||
|
logoutAction: "KEEP_ORIGIN_SHARED" as const,
|
||||||
|
accountDeletionAction: "KEEP_ORIGIN_SHARED" as const,
|
||||||
|
pressureAction: "RETAIN" as const,
|
||||||
|
unavailableFallback: "READ_ONLY" as const,
|
||||||
|
});
|
||||||
|
|
||||||
|
const config: IndexedDbRuntimeDependencies<
|
||||||
|
LocalDraft,
|
||||||
|
LocalDraft,
|
||||||
|
never
|
||||||
|
> = {
|
||||||
|
scope,
|
||||||
|
storagePolicy,
|
||||||
|
schemaVersion: 1,
|
||||||
|
recordStore: "records",
|
||||||
|
governanceStore: "governance",
|
||||||
|
retentionStore: "retention",
|
||||||
|
retentionEligibilityIndex: "by-eligibility",
|
||||||
|
lifecycleMetadataStores: [],
|
||||||
|
idempotencyStore: "receipts",
|
||||||
|
idempotencyExpiryIndex: "by-expiry",
|
||||||
|
receiptRetentionMs: 60_000,
|
||||||
|
maxIdempotencyReceipts: 100,
|
||||||
|
migrations: [
|
||||||
|
{
|
||||||
|
id: "local-draft-schema-v1",
|
||||||
|
fromVersion: 0,
|
||||||
|
toVersion: 1,
|
||||||
|
operations: [
|
||||||
|
{
|
||||||
|
kind: "CREATE_STORE",
|
||||||
|
name: "governance",
|
||||||
|
keyPath: "bindingKey",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "CREATE_STORE",
|
||||||
|
name: "retention",
|
||||||
|
keyPath: "recordKey",
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
name: "by-eligibility",
|
||||||
|
keyPath: "eligibleAtEpochMs",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "CREATE_STORE",
|
||||||
|
name: "records",
|
||||||
|
keyPath: "key",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "CREATE_STORE",
|
||||||
|
name: "receipts",
|
||||||
|
keyPath: "idempotencyKey",
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
name: "by-expiry",
|
||||||
|
keyPath: "expiresAtEpochMs",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
codec: {
|
||||||
|
currentVersion: 1,
|
||||||
|
encode: (value) => ({ ok: true, value }),
|
||||||
|
measureStoredBytes: (value) =>
|
||||||
|
new TextEncoder().encode(JSON.stringify(value)).byteLength,
|
||||||
|
decode: (version, value) => {
|
||||||
|
if (
|
||||||
|
version !== 1 ||
|
||||||
|
value === null ||
|
||||||
|
typeof value !== "object"
|
||||||
|
) {
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
const candidate = value as Partial<LocalDraft>;
|
||||||
|
return typeof candidate.draftId === "string" &&
|
||||||
|
typeof candidate.title === "string" &&
|
||||||
|
typeof candidate.body === "string"
|
||||||
|
? { ok: true, value: candidate as LocalDraft }
|
||||||
|
: { ok: false };
|
||||||
|
},
|
||||||
|
fingerprint: async (value) => {
|
||||||
|
const bytes = new TextEncoder().encode(
|
||||||
|
JSON.stringify([value.draftId, value.title, value.body]),
|
||||||
|
);
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||||
|
return Array.from(
|
||||||
|
new Uint8Array(digest),
|
||||||
|
(byte) => byte.toString(16).padStart(2, "0"),
|
||||||
|
).join("");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
queryPolicy: {
|
||||||
|
plan: () => ({ limit: 1 }),
|
||||||
|
},
|
||||||
|
authorizeLifecycle: () => ({
|
||||||
|
authorized: true,
|
||||||
|
proofToken: "localdraftauthorityproof_001",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const runtime = createIndexedDbRuntime(config);
|
||||||
|
const databaseName = indexedDbPhysicalDatabaseName(scope);
|
||||||
|
const opened = await runtime.open();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const indexedDb = createIndexedDbRepositoryProvider(
|
||||||
|
Object.freeze({ "local-draft": runtime }),
|
||||||
|
);
|
||||||
|
const composed = composeFeatureAdapterInputs(
|
||||||
|
Object.freeze([LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION] as const),
|
||||||
|
Object.freeze(["local-draft"]),
|
||||||
|
Object.freeze({ indexedDb }),
|
||||||
|
);
|
||||||
|
const localDraft = composed["local-draft"];
|
||||||
|
if (!localDraft) {
|
||||||
|
throw new Error("local-draft feature input was not composed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const draft = Object.freeze({
|
||||||
|
draftId: "draft-1",
|
||||||
|
title: "Architecture notes",
|
||||||
|
body: "Stored through the reusable IndexedDB capability.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await localDraft.saveDraft({
|
||||||
|
draft,
|
||||||
|
expectedRevision: null,
|
||||||
|
idempotencyKey: "local-draft-create-1",
|
||||||
|
});
|
||||||
|
const found = await localDraft.findDraft(draft.draftId);
|
||||||
|
const removed = await localDraft.removeDraft({
|
||||||
|
draftId: draft.draftId,
|
||||||
|
expectedRevision: saved.ok ? saved.value.revision : 1,
|
||||||
|
idempotencyKey: "local-draft-remove-1",
|
||||||
|
});
|
||||||
|
const afterRemove = await localDraft.findDraft(draft.draftId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
opened,
|
||||||
|
saved,
|
||||||
|
found,
|
||||||
|
removed,
|
||||||
|
afterRemove,
|
||||||
|
composedFeatureIds: Object.keys(composed),
|
||||||
|
runtimeStatus: runtime.getStatus(),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
runtime.close();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const request = indexedDB.deleteDatabase(databaseName);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () =>
|
||||||
|
reject(request.error ?? new Error("IndexedDB cleanup failed"));
|
||||||
|
request.onblocked = () =>
|
||||||
|
reject(new Error("IndexedDB cleanup was blocked"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.opened).toEqual({ ok: true, value: undefined });
|
||||||
|
expect(result.composedFeatureIds).toEqual(["local-draft"]);
|
||||||
|
expect(result.saved).toEqual({ ok: true, value: { revision: 1 } });
|
||||||
|
expect(result.found).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
draft: {
|
||||||
|
draftId: "draft-1",
|
||||||
|
title: "Architecture notes",
|
||||||
|
body: "Stored through the reusable IndexedDB capability.",
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.removed).toEqual({ ok: true, value: undefined });
|
||||||
|
expect(result.afterRemove).toEqual({ ok: true, value: null });
|
||||||
|
expect(result.runtimeStatus).toMatchObject({
|
||||||
|
kind: "READY",
|
||||||
|
schemaVersion: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,29 +1,48 @@
|
|||||||
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
import { expect, test } from "../support/browser/strict-browser-test.ts";
|
||||||
|
|
||||||
test("executes the storage durability adapter against the real browser StorageManager", async ({
|
test("executes the storage durability adapter against the real browser StorageManager capability", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
const result = await page.evaluate(async () => {
|
const observation = await page.evaluate(async () => {
|
||||||
const modulePath =
|
const modulePath =
|
||||||
"/src/adapters/browser-file-storage/storage-manager-adapter.ts";
|
"/src/adapters/browser-file-storage/storage-manager-adapter.ts";
|
||||||
const { createStorageDurabilityAdapter } = await import(
|
const { createStorageDurabilityAdapter } = await import(
|
||||||
/* @vite-ignore */ modulePath
|
/* @vite-ignore */ modulePath
|
||||||
);
|
);
|
||||||
|
const hasStorageManager = navigator.storage !== undefined;
|
||||||
const adapter = createStorageDurabilityAdapter(navigator.storage);
|
const adapter = createStorageDurabilityAdapter(navigator.storage);
|
||||||
return adapter.inspect();
|
return {
|
||||||
|
hasStorageManager,
|
||||||
|
result: await adapter.inspect(),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
if (!observation.hasStorageManager) {
|
||||||
if (result.ok) {
|
expect(observation.result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "UNSUPPORTED",
|
||||||
|
operation: "STORAGE_ESTIMATE",
|
||||||
|
retryable: false,
|
||||||
|
recovery: "ONLINE_ONLY",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(observation.result.ok).toBe(true);
|
||||||
|
if (observation.result.ok) {
|
||||||
expect(["UNKNOWN", "NORMAL", "PRESSURE", "CRITICAL"]).toContain(
|
expect(["UNKNOWN", "NORMAL", "PRESSURE", "CRITICAL"]).toContain(
|
||||||
result.value.pressure,
|
observation.result.value.pressure,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
result.value.usageBytes === null || result.value.usageBytes >= 0,
|
observation.result.value.usageBytes === null ||
|
||||||
|
observation.result.value.usageBytes >= 0,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
result.value.quotaBytes === null || result.value.quotaBytes >= 0,
|
observation.result.value.quotaBytes === null ||
|
||||||
|
observation.result.value.quotaBytes >= 0,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ describe("generic application router", () => {
|
|||||||
await screen.findByRole("heading", { name: "UI 구성요소", level: 1 }),
|
await screen.findByRole("heading", { name: "UI 구성요소", level: 1 }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect(window.location.pathname).toBe("/examples/ui");
|
expect(window.location.pathname).toBe("/examples/ui");
|
||||||
expect(document.title).toBe("UI 구성요소 · Frontend Skeleton");
|
await waitFor(() =>
|
||||||
|
expect(document.title).toBe("UI 구성요소 · Frontend Skeleton"),
|
||||||
|
);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("heading", { name: "UI 구성요소", level: 1 }),
|
screen.getByRole("heading", { name: "UI 구성요소", level: 1 }),
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
IndexedDbRepositoryPort,
|
||||||
|
} from "../../../src/application/ports/browser-file-storage/index.ts";
|
||||||
|
import type {
|
||||||
|
InstalledHttpOperationExecutor,
|
||||||
|
} from "../../../src/adapters/http/index.ts";
|
||||||
|
import {
|
||||||
|
composeFeatureAdapterInputs,
|
||||||
|
defineFeatureAdapterContribution,
|
||||||
|
type IndexedDbRepositoryProvider,
|
||||||
|
} from "../../../src/features/feature-adapter-contribution.ts";
|
||||||
|
import {
|
||||||
|
LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION,
|
||||||
|
} from "../../../src/features/local-draft-feature/adapters/create-local-draft-feature-input.ts";
|
||||||
|
import type { LocalDraft } from "../../../src/features/local-draft-feature/domain/local-draft.ts";
|
||||||
|
|
||||||
|
type TestHttpFeatureInput = Readonly<{
|
||||||
|
ping(): Promise<"pong">;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
declare module "../../../src/application/ports/in/application-api.ts" {
|
||||||
|
interface ApplicationFeatureInputs {
|
||||||
|
"test-http-feature": TestHttpFeatureInput;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEST_HTTP_FEATURE_ADAPTER_CONTRIBUTION =
|
||||||
|
defineFeatureAdapterContribution({
|
||||||
|
featureId: "test-http-feature",
|
||||||
|
needs: ["http"] as const,
|
||||||
|
createInput() {
|
||||||
|
return Object.freeze({
|
||||||
|
featureId: "test-http-feature" as const,
|
||||||
|
input: Object.freeze({
|
||||||
|
async ping() {
|
||||||
|
return "pong" as const;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function localDraftRepository() {
|
||||||
|
let stored: Readonly<{ value: LocalDraft; revision: number }> | null = null;
|
||||||
|
const repository = Object.freeze({
|
||||||
|
async compareAndSwap(input: Readonly<{ value: LocalDraft }>) {
|
||||||
|
stored = Object.freeze({ value: input.value, revision: 1 });
|
||||||
|
return Object.freeze({
|
||||||
|
ok: true as const,
|
||||||
|
value: Object.freeze({ revision: 1 }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async read() {
|
||||||
|
return Object.freeze({ ok: true as const, value: stored });
|
||||||
|
},
|
||||||
|
async remove() {
|
||||||
|
stored = null;
|
||||||
|
return Object.freeze({
|
||||||
|
ok: true as const,
|
||||||
|
value: Object.freeze({ revision: 2 }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}) as unknown as IndexedDbRepositoryPort<LocalDraft, never>;
|
||||||
|
return repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("feature adapter composition", () => {
|
||||||
|
it("composes HTTP-only and IndexedDB-only concrete contributions together", async () => {
|
||||||
|
const repository = localDraftRepository();
|
||||||
|
const get = ((repositoryId: string) => {
|
||||||
|
expect(repositoryId).toBe("local-draft");
|
||||||
|
return repository;
|
||||||
|
}) as unknown as IndexedDbRepositoryProvider["get"];
|
||||||
|
|
||||||
|
const indexedDb = Object.freeze({ get }) satisfies IndexedDbRepositoryProvider;
|
||||||
|
const http = Object.freeze({
|
||||||
|
execute: async () => {
|
||||||
|
throw new Error("HTTP should not execute during composition");
|
||||||
|
},
|
||||||
|
}) as unknown as InstalledHttpOperationExecutor;
|
||||||
|
|
||||||
|
const inputs = composeFeatureAdapterInputs(
|
||||||
|
Object.freeze([
|
||||||
|
TEST_HTTP_FEATURE_ADAPTER_CONTRIBUTION,
|
||||||
|
LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION,
|
||||||
|
] as const),
|
||||||
|
Object.freeze(["test-http-feature", "local-draft"]),
|
||||||
|
Object.freeze({ http, indexedDb }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(Object.keys(inputs).sort()).toEqual([
|
||||||
|
"local-draft",
|
||||||
|
"test-http-feature",
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(inputs["test-http-feature"]?.ping()).resolves.toBe("pong");
|
||||||
|
|
||||||
|
const localDraft = inputs["local-draft"];
|
||||||
|
if (!localDraft) throw new Error("local-draft input was not composed");
|
||||||
|
|
||||||
|
const draft = Object.freeze({
|
||||||
|
draftId: "draft-1",
|
||||||
|
title: "Local draft",
|
||||||
|
body: "Body",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
localDraft.saveDraft({
|
||||||
|
draft,
|
||||||
|
expectedRevision: null,
|
||||||
|
idempotencyKey: "save-draft-1",
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ ok: true, value: { revision: 1 } });
|
||||||
|
|
||||||
|
await expect(localDraft.findDraft("draft-1")).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
value: { draft, revision: 1 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,57 +3,129 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import {
|
import {
|
||||||
createFeatureHttpBinding,
|
createFeatureHttpBinding,
|
||||||
defineFeatureHttpOperation,
|
defineFeatureHttpOperation,
|
||||||
|
type HttpExecutionOutcome,
|
||||||
type InstalledHttpOperationExecutor,
|
type InstalledHttpOperationExecutor,
|
||||||
} from "../../../src/adapters/http/index.ts";
|
} from "../../../src/adapters/http/index.ts";
|
||||||
import {
|
import {
|
||||||
mappingFailure,
|
mappingFailure,
|
||||||
mappingSuccess,
|
mappingSuccess,
|
||||||
} from "../../../src/contracts/boundary-mapper.ts";
|
} from "../../../src/contracts/boundary-mapper.ts";
|
||||||
|
import type {
|
||||||
|
InstalledHttpContract,
|
||||||
|
RuntimeValidator,
|
||||||
|
} from "../../../src/contracts/external-contract-runtime.ts";
|
||||||
|
|
||||||
|
type ResourceInput = Readonly<{ resourceId: string }>;
|
||||||
|
type ResourceWire = Readonly<{ id: string; title: string }>;
|
||||||
|
type ResourceProblem = Readonly<{ code?: string }>;
|
||||||
type Resource = Readonly<{ id: string; title: string }>;
|
type Resource = Readonly<{ id: string; title: string }>;
|
||||||
|
|
||||||
const OPERATIONS = Object.freeze({
|
function validator<T>(schemaId: string): RuntimeValidator<T> {
|
||||||
LOAD_RESOURCE: defineFeatureHttpOperation<
|
return Object.freeze({
|
||||||
Readonly<{ resourceId: string }>,
|
schemaId,
|
||||||
Resource
|
safeParse(value: unknown) {
|
||||||
>({
|
return Object.freeze({
|
||||||
|
success: true as const,
|
||||||
|
data: value as T,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOAD_RESOURCE_CONTRACT = Object.freeze({
|
||||||
|
contract: Object.freeze({
|
||||||
operationId: "LOAD_RESOURCE",
|
operationId: "LOAD_RESOURCE",
|
||||||
|
method: "GET" as const,
|
||||||
|
pathTemplate: "/resources/{resourceId}",
|
||||||
|
inputValidator: validator<ResourceInput>("ResourceInput"),
|
||||||
|
outputValidator: validator<ResourceWire>("ResourceWire"),
|
||||||
|
problemValidator: validator<ResourceProblem>("ResourceProblem"),
|
||||||
|
acceptedStatuses: Object.freeze([200]),
|
||||||
|
emptyBodyStatuses: Object.freeze([]),
|
||||||
|
retrySemantics: "SAFE" as const,
|
||||||
|
requestBody: "NONE" as const,
|
||||||
|
responseBody: "REQUIRED_JSON" as const,
|
||||||
|
commandRecovery: null,
|
||||||
|
commandEffect: null,
|
||||||
|
projectRequest(input: ResourceInput) {
|
||||||
|
return Object.freeze({
|
||||||
|
pathValues: Object.freeze({ resourceId: input.resourceId }),
|
||||||
|
queryEntries: Object.freeze([]),
|
||||||
|
body: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
frontend: Object.freeze({
|
||||||
|
policyId: "TEST_LOAD_RESOURCE",
|
||||||
|
requestByteLimit: 0,
|
||||||
|
responseByteLimit: 8_192,
|
||||||
|
totalDeadlineMs: 1_000,
|
||||||
|
retryBudget: 0 as const,
|
||||||
|
authProfileId: "TEST",
|
||||||
|
diagnosticsOperation: "test.load-resource",
|
||||||
|
}),
|
||||||
|
}) satisfies InstalledHttpContract<ResourceInput, ResourceWire, ResourceProblem>;
|
||||||
|
|
||||||
|
const OPERATIONS = Object.freeze({
|
||||||
|
LOAD_RESOURCE: defineFeatureHttpOperation({
|
||||||
|
contract: LOAD_RESOURCE_CONTRACT,
|
||||||
routeId: "RESOURCE_DETAIL",
|
routeId: "RESOURCE_DETAIL",
|
||||||
mapSuccess(value) {
|
mapSuccess(value) {
|
||||||
if (
|
if (value.title.length === 0) {
|
||||||
!value ||
|
|
||||||
typeof value !== "object" ||
|
|
||||||
typeof (value as Record<string, unknown>).id !== "string" ||
|
|
||||||
typeof (value as Record<string, unknown>).title !== "string"
|
|
||||||
) {
|
|
||||||
return mappingFailure("MAPPING_INVARIANT_REJECTED");
|
return mappingFailure("MAPPING_INVARIANT_REJECTED");
|
||||||
}
|
}
|
||||||
const candidate = value as Readonly<{ id: string; title: string }>;
|
|
||||||
return mappingSuccess(
|
return mappingSuccess(
|
||||||
Object.freeze({ id: candidate.id, title: candidate.title }),
|
Object.freeze({ id: value.id, title: value.title }),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
mapProblem(problem, metadata) {
|
||||||
|
if (metadata.status !== 409) return undefined;
|
||||||
|
return Object.freeze({
|
||||||
|
kind: "CONFLICT" as const,
|
||||||
|
code: problem.code ?? "RESOURCE_CONFLICT",
|
||||||
|
});
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
} as const);
|
} as const);
|
||||||
|
|
||||||
|
function scriptedExecutor<WireOutput, Problem>(
|
||||||
|
outcome: HttpExecutionOutcome<WireOutput, Problem>,
|
||||||
|
) {
|
||||||
|
const calls: Array<
|
||||||
|
Readonly<{
|
||||||
|
contract: unknown;
|
||||||
|
input: unknown;
|
||||||
|
context: unknown;
|
||||||
|
}>
|
||||||
|
> = [];
|
||||||
|
const implementation = async (
|
||||||
|
contract: unknown,
|
||||||
|
input: unknown,
|
||||||
|
context: unknown,
|
||||||
|
) => {
|
||||||
|
calls.push(Object.freeze({ contract, input, context }));
|
||||||
|
return outcome;
|
||||||
|
};
|
||||||
|
const execute = vi.fn(implementation) as unknown as
|
||||||
|
InstalledHttpOperationExecutor["execute"];
|
||||||
|
return {
|
||||||
|
executor: Object.freeze({ execute }) satisfies InstalledHttpOperationExecutor,
|
||||||
|
calls,
|
||||||
|
execute,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("feature HTTP binding", () => {
|
describe("feature HTTP binding", () => {
|
||||||
it("keeps typed feature input while platform owns route/context execution", async () => {
|
it("keeps contract-derived input while platform owns route/context execution", async () => {
|
||||||
const execute = vi.fn<InstalledHttpOperationExecutor["execute"]>(
|
const scripted = scriptedExecutor<ResourceWire, ResourceProblem>(
|
||||||
async (_operationId, input, context) => {
|
Object.freeze({
|
||||||
expect(input).toEqual({ resourceId: "resource-1" });
|
|
||||||
expect(context.routeId).toBe("RESOURCE_DETAIL");
|
|
||||||
return Object.freeze({
|
|
||||||
kind: "SUCCESS" as const,
|
kind: "SUCCESS" as const,
|
||||||
value: Object.freeze({ id: "resource-1", title: "Reference" }),
|
value: Object.freeze({ id: "resource-1", title: "Reference" }),
|
||||||
metadata: Object.freeze({ status: 200 }),
|
metadata: Object.freeze({ status: 200 }),
|
||||||
effect: "NOT_APPLICABLE" as const,
|
effect: "NOT_APPLICABLE" as const,
|
||||||
});
|
}),
|
||||||
},
|
|
||||||
);
|
|
||||||
const binding = createFeatureHttpBinding(
|
|
||||||
Object.freeze({ execute }),
|
|
||||||
OPERATIONS,
|
|
||||||
);
|
);
|
||||||
|
const binding = createFeatureHttpBinding(scripted.executor, OPERATIONS);
|
||||||
|
|
||||||
const result = await binding.execute("LOAD_RESOURCE", {
|
const result = await binding.execute("LOAD_RESOURCE", {
|
||||||
resourceId: "resource-1",
|
resourceId: "resource-1",
|
||||||
@@ -63,23 +135,27 @@ describe("feature HTTP binding", () => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
value: { id: "resource-1", title: "Reference" },
|
value: { id: "resource-1", title: "Reference" },
|
||||||
});
|
});
|
||||||
expect(execute).toHaveBeenCalledTimes(1);
|
expect(scripted.calls).toEqual([
|
||||||
|
{
|
||||||
|
contract: LOAD_RESOURCE_CONTRACT,
|
||||||
|
input: { resourceId: "resource-1" },
|
||||||
|
context: { routeId: "RESOURCE_DETAIL" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes transport failure before it crosses the feature gateway", async () => {
|
it("normalizes transport failure before it crosses the feature gateway", async () => {
|
||||||
const executor: InstalledHttpOperationExecutor = Object.freeze({
|
const scripted = scriptedExecutor<ResourceWire, ResourceProblem>(
|
||||||
async execute() {
|
Object.freeze({
|
||||||
return Object.freeze({
|
|
||||||
kind: "TRANSPORT_FAILURE" as const,
|
kind: "TRANSPORT_FAILURE" as const,
|
||||||
failure: Object.freeze({
|
failure: Object.freeze({
|
||||||
kind: "TIMEOUT" as const,
|
kind: "TIMEOUT" as const,
|
||||||
retryable: true,
|
retryable: true,
|
||||||
}),
|
}),
|
||||||
effect: "NOT_STARTED" as const,
|
effect: "NOT_STARTED" as const,
|
||||||
});
|
}),
|
||||||
},
|
);
|
||||||
});
|
const binding = createFeatureHttpBinding(scripted.executor, OPERATIONS);
|
||||||
const binding = createFeatureHttpBinding(executor, OPERATIONS);
|
|
||||||
|
|
||||||
const result = await binding.execute("LOAD_RESOURCE", {
|
const result = await binding.execute("LOAD_RESOURCE", {
|
||||||
resourceId: "resource-1",
|
resourceId: "resource-1",
|
||||||
@@ -92,18 +168,16 @@ describe("feature HTTP binding", () => {
|
|||||||
expect(result.error.effect).toBe("NOT_STARTED");
|
expect(result.error.effect).toBe("NOT_STARTED");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("turns feature mapper rejection into the shared mapping failure", async () => {
|
it("turns a domain mapper rejection into the shared mapping failure", async () => {
|
||||||
const executor: InstalledHttpOperationExecutor = Object.freeze({
|
const scripted = scriptedExecutor<ResourceWire, ResourceProblem>(
|
||||||
async execute() {
|
Object.freeze({
|
||||||
return Object.freeze({
|
|
||||||
kind: "SUCCESS" as const,
|
kind: "SUCCESS" as const,
|
||||||
value: Object.freeze({ unexpected: true }),
|
value: Object.freeze({ id: "resource-1", title: "" }),
|
||||||
metadata: Object.freeze({ status: 200 }),
|
metadata: Object.freeze({ status: 200 }),
|
||||||
effect: "NOT_APPLICABLE" as const,
|
effect: "NOT_APPLICABLE" as const,
|
||||||
});
|
}),
|
||||||
},
|
);
|
||||||
});
|
const binding = createFeatureHttpBinding(scripted.executor, OPERATIONS);
|
||||||
const binding = createFeatureHttpBinding(executor, OPERATIONS);
|
|
||||||
|
|
||||||
const result = await binding.execute("LOAD_RESOURCE", {
|
const result = await binding.execute("LOAD_RESOURCE", {
|
||||||
resourceId: "resource-1",
|
resourceId: "resource-1",
|
||||||
@@ -114,4 +188,29 @@ describe("feature HTTP binding", () => {
|
|||||||
expect(result.error.kind).toBe("MAPPING_CONTRACT_VIOLATION");
|
expect(result.error.kind).toBe("MAPPING_CONTRACT_VIOLATION");
|
||||||
expect(result.error.code).toBe("MAPPING_INVARIANT_REJECTED");
|
expect(result.error.code).toBe("MAPPING_INVARIANT_REJECTED");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lets the feature interpret a typed business problem", async () => {
|
||||||
|
const scripted = scriptedExecutor<ResourceWire, ResourceProblem>(
|
||||||
|
Object.freeze({
|
||||||
|
kind: "PROBLEM" as const,
|
||||||
|
problem: Object.freeze({ code: "RESOURCE_NAME_EXISTS" }),
|
||||||
|
metadata: Object.freeze({ status: 409 }),
|
||||||
|
effect: "NOT_APPLIED" as const,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const binding = createFeatureHttpBinding(scripted.executor, OPERATIONS);
|
||||||
|
|
||||||
|
const result = await binding.execute("LOAD_RESOURCE", {
|
||||||
|
resourceId: "resource-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
kind: "CONFLICT",
|
||||||
|
code: "RESOURCE_NAME_EXISTS",
|
||||||
|
httpStatus: 409,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ describe("reference feature boundary contracts", () => {
|
|||||||
effect: "MAYBE_APPLIED" as const,
|
effect: "MAYBE_APPLIED" as const,
|
||||||
}));
|
}));
|
||||||
const installed = createReferenceFeatureInstalledInput({
|
const installed = createReferenceFeatureInstalledInput({
|
||||||
contractOperations: { execute },
|
http: { execute },
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { createContractHttpExecutor } from "../../../src/adapters/http/index.ts";
|
import { createContractHttpExecutor } from "../../../src/adapters/http/index.ts";
|
||||||
import { createHttpObservationProjector } from "../../../src/bootstrap/runtime-adapters.ts";
|
import { createHttpObservationProjector } from "../../../src/bootstrap/runtime-adapters.ts";
|
||||||
import { createReferenceFeatureInstalledInput } from "../../../src/features/reference-feature/adapters/create-reference-feature-input.ts";
|
import { createReferenceFeatureInstalledInput } from "../../../src/features/reference-feature/adapters/create-reference-feature-input.ts";
|
||||||
import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "../../../src/features/reference-feature/contracts/reference-feature-contract-contribution.ts";
|
|
||||||
|
|
||||||
function scopeSnapshot() {
|
function scopeSnapshot() {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
@@ -34,17 +33,9 @@ describe("reference feature HTTP diagnostics", () => {
|
|||||||
telemetry: { emit: vi.fn() },
|
telemetry: { emit: vi.fn() },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const operations = new Map(
|
|
||||||
REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION.http.map((operation) => [
|
|
||||||
operation.contract.operationId,
|
|
||||||
operation,
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
const installed = createReferenceFeatureInstalledInput({
|
const installed = createReferenceFeatureInstalledInput({
|
||||||
contractOperations: Object.freeze({
|
http: Object.freeze({
|
||||||
async execute(operationId, input, context) {
|
async execute(operation, input, context) {
|
||||||
const operation = operations.get(operationId);
|
|
||||||
if (!operation) throw new Error("Unregistered reference operation");
|
|
||||||
return contractHttp.execute(operation, input, {
|
return contractHttp.execute(operation, input, {
|
||||||
routeId: context.routeId,
|
routeId: context.routeId,
|
||||||
scope: scopeSnapshot(),
|
scope: scopeSnapshot(),
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createFeatureHttpBinding,
|
||||||
|
type InstalledHttpOperationExecutor,
|
||||||
|
} from "../../../src/adapters/http/index.ts";
|
||||||
|
import {
|
||||||
|
createReferenceHttpGateway,
|
||||||
|
REFERENCE_HTTP_OPERATIONS,
|
||||||
|
} from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts";
|
||||||
|
|
||||||
|
function executorReturning(
|
||||||
|
outcome: Readonly<Record<string, unknown>>,
|
||||||
|
): InstalledHttpOperationExecutor {
|
||||||
|
const execute = (async () => outcome) as unknown as
|
||||||
|
InstalledHttpOperationExecutor["execute"];
|
||||||
|
return Object.freeze({ execute });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("reference HTTP business problem mapping", () => {
|
||||||
|
it("maps a typed 409 reference problem to the feature-owned business code", async () => {
|
||||||
|
const executor = executorReturning(
|
||||||
|
Object.freeze({
|
||||||
|
kind: "PROBLEM",
|
||||||
|
problem: Object.freeze({
|
||||||
|
type: "https://example.test/problems/reference-conflict",
|
||||||
|
title: "Reference conflict",
|
||||||
|
status: 409,
|
||||||
|
code: "REFERENCE_NAME_ALREADY_EXISTS",
|
||||||
|
}),
|
||||||
|
metadata: Object.freeze({ status: 409 }),
|
||||||
|
effect: "NOT_APPLIED",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const gateway = createReferenceHttpGateway(
|
||||||
|
createFeatureHttpBinding(executor, REFERENCE_HTTP_OPERATIONS),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
gateway.create({ name: "duplicate" }),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
kind: "CONFLICT",
|
||||||
|
code: "REFERENCE_NAME_ALREADY_EXISTS",
|
||||||
|
httpStatus: 409,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
status: 409,
|
||||||
|
code: undefined,
|
||||||
|
expectedKind: "CONFLICT",
|
||||||
|
expectedCode: "REFERENCE_RESOURCE_CONFLICT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 422,
|
||||||
|
code: "REFERENCE_NAME_REJECTED",
|
||||||
|
expectedKind: "VALIDATION_REJECTED",
|
||||||
|
expectedCode: "REFERENCE_NAME_REJECTED",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 422,
|
||||||
|
code: undefined,
|
||||||
|
expectedKind: "VALIDATION_REJECTED",
|
||||||
|
expectedCode: "REFERENCE_RESOURCE_REJECTED",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
code: "REFERENCE_BAD_REQUEST",
|
||||||
|
expectedKind: "UNKNOWN_CLIENT_FAILURE",
|
||||||
|
expectedCode: "CONTRACT_PROBLEM",
|
||||||
|
},
|
||||||
|
] as const)(
|
||||||
|
"projects create problem status $status through the feature-owned mapper/fallback",
|
||||||
|
async ({ status, code, expectedKind, expectedCode }) => {
|
||||||
|
const executor = executorReturning(
|
||||||
|
Object.freeze({
|
||||||
|
kind: "PROBLEM",
|
||||||
|
problem: Object.freeze({
|
||||||
|
type: "https://example.test/problems/reference-" + status,
|
||||||
|
title: "Reference request rejected",
|
||||||
|
status,
|
||||||
|
...(code === undefined ? {} : { code }),
|
||||||
|
}),
|
||||||
|
metadata: Object.freeze({ status }),
|
||||||
|
effect: "NOT_APPLIED",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const gateway = createReferenceHttpGateway(
|
||||||
|
createFeatureHttpBinding(executor, REFERENCE_HTTP_OPERATIONS),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
gateway.create({ name: "rejected" }),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
kind: expectedKind,
|
||||||
|
code: expectedCode,
|
||||||
|
httpStatus: status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("keeps the platform status fallback when the operation has no problem mapper", async () => {
|
||||||
|
const executor = executorReturning(
|
||||||
|
Object.freeze({
|
||||||
|
kind: "PROBLEM",
|
||||||
|
problem: Object.freeze({
|
||||||
|
type: "https://example.test/problems/not-found",
|
||||||
|
title: "Not found",
|
||||||
|
status: 404,
|
||||||
|
}),
|
||||||
|
metadata: Object.freeze({ status: 404 }),
|
||||||
|
effect: "NOT_APPLIED",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const gateway = createReferenceHttpGateway(
|
||||||
|
createFeatureHttpBinding(executor, REFERENCE_HTTP_OPERATIONS),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(gateway.get("missing")).resolves.toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
kind: "NOT_FOUND",
|
||||||
|
code: "CONTRACT_PROBLEM",
|
||||||
|
httpStatus: 404,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type InstalledHttpOperationExecutor,
|
||||||
|
} from "../../../src/adapters/http/index.ts";
|
||||||
import { createReferenceFeatureInstalledInput } from "../../../src/features/reference-feature/adapters/create-reference-feature-input.ts";
|
import { createReferenceFeatureInstalledInput } from "../../../src/features/reference-feature/adapters/create-reference-feature-input.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,22 +17,31 @@ import { createReferenceFeatureInstalledInput } from "../../../src/features/refe
|
|||||||
describe("reference feature installed operation executor", () => {
|
describe("reference feature installed operation executor", () => {
|
||||||
it("preserves the feature route id through the installed operation executor", async () => {
|
it("preserves the feature route id through the installed operation executor", async () => {
|
||||||
const seen: Array<Readonly<Record<string, unknown>>> = [];
|
const seen: Array<Readonly<Record<string, unknown>>> = [];
|
||||||
const installed = createReferenceFeatureInstalledInput({
|
const implementation = async (
|
||||||
contractOperations: Object.freeze({
|
contract: Readonly<{ contract: Readonly<{ operationId: string }> }>,
|
||||||
async execute(
|
|
||||||
_operationId: string,
|
|
||||||
_input: unknown,
|
_input: unknown,
|
||||||
context?: Readonly<Record<string, unknown>>,
|
context: Readonly<Record<string, unknown>>,
|
||||||
) {
|
) => {
|
||||||
seen.push(Object.freeze({ ...(context ?? {}) }));
|
seen.push(Object.freeze({ ...context }));
|
||||||
|
const value =
|
||||||
|
contract.contract.operationId === "LIST_REFERENCE_RESOURCES"
|
||||||
|
? []
|
||||||
|
: {
|
||||||
|
id: "resource-1",
|
||||||
|
name: "Resource",
|
||||||
|
};
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
kind: "SUCCESS" as const,
|
kind: "SUCCESS" as const,
|
||||||
value: [],
|
value,
|
||||||
metadata: Object.freeze({ status: 200 }),
|
metadata: Object.freeze({ status: 200 }),
|
||||||
effect: "NOT_APPLICABLE" as const,
|
effect: "NOT_APPLICABLE" as const,
|
||||||
});
|
});
|
||||||
},
|
};
|
||||||
}),
|
const execute = implementation as unknown as
|
||||||
|
InstalledHttpOperationExecutor["execute"];
|
||||||
|
|
||||||
|
const installed = createReferenceFeatureInstalledInput({
|
||||||
|
http: Object.freeze({ execute }),
|
||||||
});
|
});
|
||||||
|
|
||||||
await installed.input.listResources({ limit: 20 });
|
await installed.input.listResources({ limit: 20 });
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@
|
|||||||
"src/application/create-application.ts": {
|
"src/application/create-application.ts": {
|
||||||
"lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }
|
"lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }
|
||||||
},
|
},
|
||||||
"src/application/policies/compatibility.ts": {
|
"src/contracts/compatibility.ts": {
|
||||||
"lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }
|
"lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }
|
||||||
},
|
},
|
||||||
"src/application/policies/performance-budgets.ts": {
|
"src/application/policies/performance-budgets.ts": {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { InstalledHttpOperationExecutor } from "../../../src/adapters/http/index.ts";
|
||||||
|
import { composeFeatureAdapterInputs } from "../../../src/features/feature-adapter-contribution.ts";
|
||||||
|
|
||||||
|
declare module "../../../src/application/ports/in/application-api.ts" {
|
||||||
|
interface ApplicationFeatureInputs {
|
||||||
|
"direct-compose-probe": Readonly<{
|
||||||
|
ping(): "pong";
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const malformedRawContribution = Object.freeze({
|
||||||
|
featureId: "direct-compose-probe" as const,
|
||||||
|
needs: ["http"] as const,
|
||||||
|
createInput() {
|
||||||
|
return Object.freeze({
|
||||||
|
featureId: "direct-compose-probe" as const,
|
||||||
|
input: Object.freeze({
|
||||||
|
ping() {
|
||||||
|
return "pong" as const;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
composeFeatureAdapterInputs(
|
||||||
|
Object.freeze([malformedRawContribution] as const),
|
||||||
|
Object.freeze(["direct-compose-probe"]),
|
||||||
|
Object.freeze({
|
||||||
|
http: null as unknown as InstalledHttpOperationExecutor,
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineFeatureAdapterContribution } from "../../../src/features/feature-adapter-contribution.ts";
|
||||||
|
import { createReferenceFeatureInstalledInput } from "../../../src/features/reference-feature/adapters/create-reference-feature-input.ts";
|
||||||
|
|
||||||
|
defineFeatureAdapterContribution({
|
||||||
|
featureId: "reference-feature",
|
||||||
|
needs: ["http"] as const,
|
||||||
|
createInput(context) {
|
||||||
|
context.indexedDb;
|
||||||
|
return createReferenceFeatureInstalledInput({
|
||||||
|
http: context.http,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineFeatureAdapterContribution } from "../../../src/features/feature-adapter-contribution.ts";
|
||||||
|
import type {} from "../../../src/features/reference-feature/application/reference-feature-api.ts";
|
||||||
|
|
||||||
|
defineFeatureAdapterContribution({
|
||||||
|
featureId: "reference-feature",
|
||||||
|
needs: ["http"] as const,
|
||||||
|
createInput() {
|
||||||
|
return {
|
||||||
|
featureId: "reference-feature" as const,
|
||||||
|
input: {
|
||||||
|
definitelyNotReferenceFeatureInput: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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.
|
* longer. Raising the global default instead would hide a genuinely hung test.
|
||||||
*/
|
*/
|
||||||
const PROCESS_HEAVY_TIMEOUT_MS = 30_000;
|
const PROCESS_HEAVY_TIMEOUT_MS = 30_000;
|
||||||
|
/**
|
||||||
|
* Test watchdog only. This is not a provider protocol deadline. Child-process
|
||||||
|
* scheduling can be delayed when the repository suite is under load, so the
|
||||||
|
* watchdog must not encode a one-second protocol requirement that does not
|
||||||
|
* exist in production.
|
||||||
|
*/
|
||||||
|
const PROCESS_HANDSHAKE_WATCHDOG_MS = 5_000;
|
||||||
|
|
||||||
const roots: string[] = [];
|
const roots: string[] = [];
|
||||||
|
|
||||||
@@ -216,7 +223,7 @@ describe("provider guardian transaction protocol", () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const ready = decodeProviderGuardianReady(
|
const ready = decodeProviderGuardianReady(
|
||||||
await within(readyPayload, 1_000, "guardian READY"),
|
await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian READY"),
|
||||||
nonce,
|
nonce,
|
||||||
);
|
);
|
||||||
const raw = await lstat(rawPath);
|
const raw = await lstat(rawPath);
|
||||||
@@ -231,7 +238,7 @@ describe("provider guardian transaction protocol", () => {
|
|||||||
.toEqual({ dev: ready.sealedDev, ino: ready.sealedIno, mode: 0o600 });
|
.toEqual({ dev: ready.sealedDev, ino: ready.sealedIno, mode: 0o600 });
|
||||||
|
|
||||||
child.stdin!.end();
|
child.stdin!.end();
|
||||||
await expect(within(completion, 1_000, "guardian abort")).resolves.toEqual({
|
await expect(within(completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian abort")).resolves.toEqual({
|
||||||
code: 125,
|
code: 125,
|
||||||
signal: null,
|
signal: null,
|
||||||
});
|
});
|
||||||
@@ -254,7 +261,7 @@ describe("provider guardian transaction protocol", () => {
|
|||||||
if (input) child.stdin!.end(input);
|
if (input) child.stdin!.end(input);
|
||||||
else child.stdin!.end();
|
else child.stdin!.end();
|
||||||
|
|
||||||
await expect(within(completion, 1_000, `${label} guardian EOF`)).resolves.toEqual({
|
await expect(within(completion, PROCESS_HANDSHAKE_WATCHDOG_MS, `${label} guardian EOF`)).resolves.toEqual({
|
||||||
code: expectedCode,
|
code: expectedCode,
|
||||||
signal: null,
|
signal: null,
|
||||||
});
|
});
|
||||||
@@ -289,7 +296,7 @@ describe("provider guardian transaction protocol", () => {
|
|||||||
|
|
||||||
await expect(within(
|
await expect(within(
|
||||||
waitForChild(fixture.child),
|
waitForChild(fixture.child),
|
||||||
1_000,
|
PROCESS_HANDSHAKE_WATCHDOG_MS,
|
||||||
`${boundKind} partial bootstrap exit`,
|
`${boundKind} partial bootstrap exit`,
|
||||||
)).resolves.toEqual({ code: 126, signal: null });
|
)).resolves.toEqual({ code: 126, signal: null });
|
||||||
const boundPath = boundKind === "raw" ? fixture.rawStagingPath : fixture.sealedTempPath;
|
const boundPath = boundKind === "raw" ? fixture.rawStagingPath : fixture.sealedTempPath;
|
||||||
@@ -320,7 +327,7 @@ describe("provider guardian transaction protocol", () => {
|
|||||||
deadlineEpochMs: Date.now() + 2_000,
|
deadlineEpochMs: Date.now() + 2_000,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await expect(within(completion, 1_000, "guardian parent startup death"))
|
await expect(within(completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian parent startup death"))
|
||||||
.resolves.toEqual({ code: 125, signal: null });
|
.resolves.toEqual({ code: 125, signal: null });
|
||||||
await expect(readdir(path.join(workspace, "provider-evidence/untrusted"))).resolves.toEqual([]);
|
await expect(readdir(path.join(workspace, "provider-evidence/untrusted"))).resolves.toEqual([]);
|
||||||
await expect(readdir(path.join(workspace, "provider-evidence"))).resolves.toEqual(["untrusted"]);
|
await expect(readdir(path.join(workspace, "provider-evidence"))).resolves.toEqual(["untrusted"]);
|
||||||
@@ -348,7 +355,7 @@ describe("provider guardian transaction protocol", () => {
|
|||||||
deadlineEpochMs: Date.now() + 3_000,
|
deadlineEpochMs: Date.now() + 3_000,
|
||||||
}));
|
}));
|
||||||
const ready = decodeProviderGuardianReady(
|
const ready = decodeProviderGuardianReady(
|
||||||
await within(readyPayload, 1_000, "guardian READY"),
|
await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian READY"),
|
||||||
nonce,
|
nonce,
|
||||||
);
|
);
|
||||||
const sealedTempPath = path.join(evidenceRoot, ready.sealedTempLeaf);
|
const sealedTempPath = path.join(evidenceRoot, ready.sealedTempLeaf);
|
||||||
@@ -379,7 +386,7 @@ describe("provider guardian transaction protocol", () => {
|
|||||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||||
}));
|
}));
|
||||||
decodeProviderGuardianPublished(
|
decodeProviderGuardianPublished(
|
||||||
await within(publishedPayload, 1_000, "guardian PUBLISHED"),
|
await within(publishedPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian PUBLISHED"),
|
||||||
nonce,
|
nonce,
|
||||||
{ dev: ready.sealedDev, ino: ready.sealedIno },
|
{ dev: ready.sealedDev, ino: ready.sealedIno },
|
||||||
);
|
);
|
||||||
@@ -393,7 +400,7 @@ describe("provider guardian transaction protocol", () => {
|
|||||||
await new Promise<void>((resolve) => setTimeout(resolve, 50));
|
await new Promise<void>((resolve) => setTimeout(resolve, 50));
|
||||||
expect(child.exitCode).toBeNull();
|
expect(child.exitCode).toBeNull();
|
||||||
child.stdin!.end();
|
child.stdin!.end();
|
||||||
await expect(within(completion, 1_000, "guardian commit EOF")).resolves.toEqual({
|
await expect(within(completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian commit EOF")).resolves.toEqual({
|
||||||
code: 0,
|
code: 0,
|
||||||
signal: null,
|
signal: null,
|
||||||
});
|
});
|
||||||
@@ -456,7 +463,7 @@ describe("provider guardian transaction protocol", () => {
|
|||||||
const transaction = await establishPublishedGuardian(workspace, Buffer.alloc(32, 0x31));
|
const transaction = await establishPublishedGuardian(workspace, Buffer.alloc(32, 0x31));
|
||||||
|
|
||||||
transaction.child.stdin!.end();
|
transaction.child.stdin!.end();
|
||||||
await expect(within(transaction.completion, 1_000, "published guardian EOF"))
|
await expect(within(transaction.completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "published guardian EOF"))
|
||||||
.resolves.toEqual({ code: 125, signal: null });
|
.resolves.toEqual({ code: 125, signal: null });
|
||||||
await assertTransactionAbsent(transaction);
|
await assertTransactionAbsent(transaction);
|
||||||
|
|
||||||
@@ -487,7 +494,7 @@ describe("provider guardian transaction protocol", () => {
|
|||||||
transaction.child.stdin!.write(Buffer.from([0, 0, 0, 1, 0x7b]));
|
transaction.child.stdin!.write(Buffer.from([0, 0, 0, 1, 0x7b]));
|
||||||
transaction.child.stdin!.end();
|
transaction.child.stdin!.end();
|
||||||
|
|
||||||
await expect(within(transaction.completion, 1_000, "guardian trailing frame"))
|
await expect(within(transaction.completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian trailing frame"))
|
||||||
.resolves.toEqual({ code: 126, signal: null });
|
.resolves.toEqual({ code: 126, signal: null });
|
||||||
await assertTransactionAbsent(transaction);
|
await assertTransactionAbsent(transaction);
|
||||||
});
|
});
|
||||||
@@ -505,7 +512,7 @@ describe("provider guardian transaction protocol", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
process.kill(lease.pid, "SIGKILL");
|
process.kill(lease.pid, "SIGKILL");
|
||||||
await expect(within(lease.prematureExit, 1_000, "guardian hard death"))
|
await expect(within(lease.prematureExit, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian hard death"))
|
||||||
.resolves.toEqual(expect.objectContaining({ message: expect.stringMatching(/SIGKILL/u) }));
|
.resolves.toEqual(expect.objectContaining({ message: expect.stringMatching(/SIGKILL/u) }));
|
||||||
await lease.abort();
|
await lease.abort();
|
||||||
await expect(lstat(lease.rawPath)).rejects.toMatchObject({ code: "ENOENT" });
|
await expect(lstat(lease.rawPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
@@ -536,8 +543,8 @@ describe("provider guardian transaction protocol", () => {
|
|||||||
});
|
});
|
||||||
process.kill(guardianPid, "SIGCONT");
|
process.kill(guardianPid, "SIGCONT");
|
||||||
|
|
||||||
await within(rawLink, 1_000, "pre-READY canonical raw link");
|
await within(rawLink, PROCESS_HANDSHAKE_WATCHDOG_MS, "pre-READY canonical raw link");
|
||||||
await expect(within(starting, 2_000, "pre-READY guardian rejection"))
|
await expect(within(starting, PROCESS_HANDSHAKE_WATCHDOG_MS, "pre-READY guardian rejection"))
|
||||||
.rejects.toThrow(/provider guardian/u);
|
.rejects.toThrow(/provider guardian/u);
|
||||||
const possibleTempLeaves = (await readdir(evidenceRoot)).filter((leaf) =>
|
const possibleTempLeaves = (await readdir(evidenceRoot)).filter((leaf) =>
|
||||||
leaf.startsWith(".vulnerability-report.json.guardian-")
|
leaf.startsWith(".vulnerability-report.json.guardian-")
|
||||||
@@ -587,7 +594,7 @@ describe("provider guardian transaction protocol", () => {
|
|||||||
await canaryHandle.close();
|
await canaryHandle.close();
|
||||||
|
|
||||||
process.kill(guardianPid, "SIGKILL");
|
process.kill(guardianPid, "SIGKILL");
|
||||||
await expect(within(starting, 2_000, "external-canary guardian rejection"))
|
await expect(within(starting, PROCESS_HANDSHAKE_WATCHDOG_MS, "external-canary guardian rejection"))
|
||||||
.rejects.toThrow(/provider guardian/u);
|
.rejects.toThrow(/provider guardian/u);
|
||||||
expect(await readFile(rawPath)).toEqual(canaryBytes);
|
expect(await readFile(rawPath)).toEqual(canaryBytes);
|
||||||
expect(await lstat(rawPath)).toMatchObject({
|
expect(await lstat(rawPath)).toMatchObject({
|
||||||
@@ -664,14 +671,14 @@ describe("provider guardian transaction protocol", () => {
|
|||||||
deadlineEpochMs: Date.now() + 3_000,
|
deadlineEpochMs: Date.now() + 3_000,
|
||||||
}));
|
}));
|
||||||
const ready = decodeProviderGuardianReady(
|
const ready = decodeProviderGuardianReady(
|
||||||
await within(readyPayload, 1_000, "closed-stderr guardian READY"),
|
await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "closed-stderr guardian READY"),
|
||||||
nonce,
|
nonce,
|
||||||
);
|
);
|
||||||
child.stderr!.destroy();
|
child.stderr!.destroy();
|
||||||
await new Promise<void>((resolve) => child.stderr!.once("close", resolve));
|
await new Promise<void>((resolve) => child.stderr!.once("close", resolve));
|
||||||
child.stdin!.end();
|
child.stdin!.end();
|
||||||
|
|
||||||
await expect(within(completion, 1_000, "closed-stderr guardian exit"))
|
await expect(within(completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "closed-stderr guardian exit"))
|
||||||
.resolves.toEqual({ code: 125, signal: null });
|
.resolves.toEqual({ code: 125, signal: null });
|
||||||
await expect(lstat(path.join(rawDirectory, "vulnerability-report.json")))
|
await expect(lstat(path.join(rawDirectory, "vulnerability-report.json")))
|
||||||
.rejects.toMatchObject({ code: "ENOENT" });
|
.rejects.toMatchObject({ code: "ENOENT" });
|
||||||
@@ -697,12 +704,12 @@ describe("provider guardian transaction protocol", () => {
|
|||||||
deadlineEpochMs: Date.now() + 350,
|
deadlineEpochMs: Date.now() + 350,
|
||||||
}));
|
}));
|
||||||
const ready = decodeProviderGuardianReady(
|
const ready = decodeProviderGuardianReady(
|
||||||
await within(readyPayload, 1_000, "deadline guardian READY"),
|
await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "deadline guardian READY"),
|
||||||
nonce,
|
nonce,
|
||||||
);
|
);
|
||||||
const sealedTempPath = path.join(evidenceRoot, ready.sealedTempLeaf);
|
const sealedTempPath = path.join(evidenceRoot, ready.sealedTempLeaf);
|
||||||
|
|
||||||
await expect(within(completion, 1_000, "guardian deadline"))
|
await expect(within(completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian deadline"))
|
||||||
.resolves.toEqual({ code: null, signal: "SIGKILL" });
|
.resolves.toEqual({ code: null, signal: "SIGKILL" });
|
||||||
await expect(lstat(rawPath)).rejects.toMatchObject({ code: "ENOENT" });
|
await expect(lstat(rawPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
await expect(lstat(sealedTempPath)).rejects.toMatchObject({ code: "ENOENT" });
|
await expect(lstat(sealedTempPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
@@ -874,7 +881,7 @@ async function establishPublishedGuardian(
|
|||||||
deadlineEpochMs: Date.now() + 3_000,
|
deadlineEpochMs: Date.now() + 3_000,
|
||||||
}));
|
}));
|
||||||
const ready = decodeProviderGuardianReady(
|
const ready = decodeProviderGuardianReady(
|
||||||
await within(readyPayload, 1_000, "guardian READY"),
|
await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian READY"),
|
||||||
nonce,
|
nonce,
|
||||||
);
|
);
|
||||||
const sealedTempPath = path.join(evidenceRoot, ready.sealedTempLeaf);
|
const sealedTempPath = path.join(evidenceRoot, ready.sealedTempLeaf);
|
||||||
@@ -896,7 +903,7 @@ async function establishPublishedGuardian(
|
|||||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||||
}));
|
}));
|
||||||
decodeProviderGuardianPublished(
|
decodeProviderGuardianPublished(
|
||||||
await within(publishedPayload, 1_000, "guardian PUBLISHED"),
|
await within(publishedPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian PUBLISHED"),
|
||||||
nonce,
|
nonce,
|
||||||
{ dev: ready.sealedDev, ino: ready.sealedIno },
|
{ dev: ready.sealedDev, ino: ready.sealedIno },
|
||||||
);
|
);
|
||||||
@@ -200,6 +200,53 @@ describe("bounded body reader", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it("abandons a pending bounded read when the operation is aborted", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const reader = {
|
||||||
|
read: vi.fn(() => new Promise<never>(() => {})),
|
||||||
|
cancel: vi.fn().mockRejectedValue(new Error("cancel ignored")),
|
||||||
|
releaseLock: vi.fn(),
|
||||||
|
};
|
||||||
|
const response = {
|
||||||
|
headers: new Headers(),
|
||||||
|
body: { getReader: () => reader },
|
||||||
|
} as unknown as Response;
|
||||||
|
|
||||||
|
const pending = readBoundedBytes(response, 8, controller.signal);
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await expect(pending).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
code: "RESPONSE_STREAM_FAILURE",
|
||||||
|
});
|
||||||
|
expect(reader.cancel).toHaveBeenCalledOnce();
|
||||||
|
expect(reader.releaseLock).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("abandons a pending forbidden-body probe when the operation is aborted", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const reader = {
|
||||||
|
read: vi.fn(() => new Promise<never>(() => {})),
|
||||||
|
cancel: vi.fn().mockRejectedValue(new Error("cancel ignored")),
|
||||||
|
releaseLock: vi.fn(),
|
||||||
|
};
|
||||||
|
const response = {
|
||||||
|
headers: new Headers(),
|
||||||
|
body: { getReader: () => reader },
|
||||||
|
} as unknown as Response;
|
||||||
|
|
||||||
|
const pending = probeForbiddenBody(response, controller.signal);
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await expect(pending).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
code: "RESPONSE_STREAM_FAILURE",
|
||||||
|
});
|
||||||
|
expect(reader.cancel).toHaveBeenCalledOnce();
|
||||||
|
expect(reader.releaseLock).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
it("decodes valid JSON and distinguishes UTF-8 from JSON failures", () => {
|
it("decodes valid JSON and distinguishes UTF-8 from JSON failures", () => {
|
||||||
expect(decodeJsonBytes(new TextEncoder().encode('{"ok":true}'))).toEqual({
|
expect(decodeJsonBytes(new TextEncoder().encode('{"ok":true}'))).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -73,12 +73,17 @@ function labelledFileInput(): HTMLInputElement {
|
|||||||
describe("browser file pickers", () => {
|
describe("browser file pickers", () => {
|
||||||
it("treats native input cancellation as a normal dismissed outcome", async () => {
|
it("treats native input cancellation as a normal dismissed outcome", async () => {
|
||||||
const input = labelledFileInput();
|
const input = labelledFileInput();
|
||||||
Object.defineProperty(input, "showPicker", {
|
Object.defineProperty(input, "click", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: vi.fn(() => {
|
value: vi.fn(() => {
|
||||||
queueMicrotask(() => input.dispatchEvent(new Event("cancel")));
|
queueMicrotask(() => input.dispatchEvent(new Event("cancel")));
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
const baselineShowPicker = vi.fn();
|
||||||
|
Object.defineProperty(input, "showPicker", {
|
||||||
|
configurable: true,
|
||||||
|
value: baselineShowPicker,
|
||||||
|
});
|
||||||
const harness = createHarness();
|
const harness = createHarness();
|
||||||
const picker = new NativeInputFilePicker({
|
const picker = new NativeInputFilePicker({
|
||||||
input,
|
input,
|
||||||
@@ -90,6 +95,7 @@ describe("browser file pickers", () => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
value: { kind: "DISMISSED" },
|
value: { kind: "DISMISSED" },
|
||||||
});
|
});
|
||||||
|
expect(baselineShowPicker).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resets the native input and supports same-file reselection", async () => {
|
it("resets the native input and supports same-file reselection", async () => {
|
||||||
@@ -102,12 +108,12 @@ describe("browser file pickers", () => {
|
|||||||
configurable: true,
|
configurable: true,
|
||||||
value: [selected],
|
value: [selected],
|
||||||
});
|
});
|
||||||
const showPicker = vi.fn(() => {
|
const click = vi.fn(() => {
|
||||||
queueMicrotask(() => input.dispatchEvent(new Event("change")));
|
queueMicrotask(() => input.dispatchEvent(new Event("change")));
|
||||||
});
|
});
|
||||||
Object.defineProperty(input, "showPicker", {
|
Object.defineProperty(input, "click", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: showPicker,
|
value: click,
|
||||||
});
|
});
|
||||||
let sequence = 0;
|
let sequence = 0;
|
||||||
const { policies, vault } = createHarness(policyDefinition, {
|
const { policies, vault } = createHarness(policyDefinition, {
|
||||||
@@ -130,14 +136,14 @@ describe("browser file pickers", () => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
value: { kind: "SELECTED" },
|
value: { kind: "SELECTED" },
|
||||||
});
|
});
|
||||||
expect(showPicker).toHaveBeenCalledTimes(2);
|
expect(click).toHaveBeenCalledTimes(2);
|
||||||
expect(input.value).toBe("");
|
expect(input.value).toBe("");
|
||||||
expect(vault.activeReferenceCount).toBe(2);
|
expect(vault.activeReferenceCount).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("snapshots selection policy before awaiting picker events", async () => {
|
it("snapshots selection policy before awaiting picker events", async () => {
|
||||||
const input = labelledFileInput();
|
const input = labelledFileInput();
|
||||||
Object.defineProperty(input, "showPicker", {
|
Object.defineProperty(input, "click", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: vi.fn(),
|
value: vi.fn(),
|
||||||
});
|
});
|
||||||
@@ -186,7 +192,7 @@ describe("browser file pickers", () => {
|
|||||||
|
|
||||||
it("honors AbortSignal while a native dialog is pending", async () => {
|
it("honors AbortSignal while a native dialog is pending", async () => {
|
||||||
const input = labelledFileInput();
|
const input = labelledFileInput();
|
||||||
Object.defineProperty(input, "showPicker", {
|
Object.defineProperty(input, "click", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: vi.fn(),
|
value: vi.fn(),
|
||||||
});
|
});
|
||||||
@@ -215,7 +221,7 @@ describe("browser file pickers", () => {
|
|||||||
let fallback: (() => void) | undefined;
|
let fallback: (() => void) | undefined;
|
||||||
let delay: number | undefined;
|
let delay: number | undefined;
|
||||||
const clearTimeout = vi.fn();
|
const clearTimeout = vi.fn();
|
||||||
Object.defineProperty(input, "showPicker", {
|
Object.defineProperty(input, "click", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: vi.fn(() => {
|
value: vi.fn(() => {
|
||||||
window.dispatchEvent(new Event("focus"));
|
window.dispatchEvent(new Event("focus"));
|
||||||
@@ -261,7 +267,7 @@ describe("browser file pickers", () => {
|
|||||||
const input = labelledFileInput();
|
const input = labelledFileInput();
|
||||||
let fallback: (() => void) | undefined;
|
let fallback: (() => void) | undefined;
|
||||||
const clearTimeout = vi.fn();
|
const clearTimeout = vi.fn();
|
||||||
Object.defineProperty(input, "showPicker", {
|
Object.defineProperty(input, "click", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: vi.fn(() => {
|
value: vi.fn(() => {
|
||||||
window.dispatchEvent(new Event("focus"));
|
window.dispatchEvent(new Event("focus"));
|
||||||
@@ -316,14 +322,14 @@ describe("browser file pickers", () => {
|
|||||||
setTimeout: originalSetTimeout,
|
setTimeout: originalSetTimeout,
|
||||||
clearTimeout: originalClearTimeout,
|
clearTimeout: originalClearTimeout,
|
||||||
};
|
};
|
||||||
const originalShowPicker = vi.fn(() => {
|
const originalClick = vi.fn(() => {
|
||||||
focus?.(new Event("focus"));
|
focus?.(new Event("focus"));
|
||||||
});
|
});
|
||||||
const replacedShowPicker = vi.fn();
|
const replacedClick = vi.fn();
|
||||||
Object.defineProperty(input, "showPicker", {
|
Object.defineProperty(input, "click", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
writable: true,
|
writable: true,
|
||||||
value: originalShowPicker,
|
value: originalClick,
|
||||||
});
|
});
|
||||||
const harness = createHarness();
|
const harness = createHarness();
|
||||||
const picker = new NativeInputFilePicker({
|
const picker = new NativeInputFilePicker({
|
||||||
@@ -338,7 +344,7 @@ describe("browser file pickers", () => {
|
|||||||
focusFallbackGraceMs: 0,
|
focusFallbackGraceMs: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
input.showPicker = replacedShowPicker;
|
input.click = replacedClick;
|
||||||
windowHost.addEventListener = vi.fn();
|
windowHost.addEventListener = vi.fn();
|
||||||
windowHost.removeEventListener = vi.fn();
|
windowHost.removeEventListener = vi.fn();
|
||||||
scheduler.setTimeout = vi.fn();
|
scheduler.setTimeout = vi.fn();
|
||||||
@@ -348,8 +354,8 @@ describe("browser file pickers", () => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
value: { kind: "DISMISSED" },
|
value: { kind: "DISMISSED" },
|
||||||
});
|
});
|
||||||
expect(originalShowPicker).toHaveBeenCalledOnce();
|
expect(originalClick).toHaveBeenCalledOnce();
|
||||||
expect(replacedShowPicker).not.toHaveBeenCalled();
|
expect(replacedClick).not.toHaveBeenCalled();
|
||||||
expect(originalWindowAdd).toHaveBeenCalledOnce();
|
expect(originalWindowAdd).toHaveBeenCalledOnce();
|
||||||
expect(originalWindowRemove).toHaveBeenCalledOnce();
|
expect(originalWindowRemove).toHaveBeenCalledOnce();
|
||||||
expect(originalSetTimeout).toHaveBeenCalledOnce();
|
expect(originalSetTimeout).toHaveBeenCalledOnce();
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ describe("browser file runtime hard limits and disposal", () => {
|
|||||||
|
|
||||||
it("aborts a pending native picker and prevents event resurrection", async () => {
|
it("aborts a pending native picker and prevents event resurrection", async () => {
|
||||||
const input = fileInput();
|
const input = fileInput();
|
||||||
Object.defineProperty(input, "showPicker", {
|
Object.defineProperty(input, "click", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: vi.fn(),
|
value: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -189,9 +189,9 @@ describe("CI gate contract", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(contract.jobs).toHaveLength(9);
|
expect(contract.jobs).toHaveLength(9);
|
||||||
expect(contract.commands).toHaveLength(84);
|
expect(contract.commands).toHaveLength(91);
|
||||||
expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(96);
|
expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(103);
|
||||||
expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(23);
|
expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(30);
|
||||||
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(88);
|
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(88);
|
||||||
expect(contract.artifacts).toHaveLength(109);
|
expect(contract.artifacts).toHaveLength(109);
|
||||||
expect(contract.stages).toHaveLength(5);
|
expect(contract.stages).toHaveLength(5);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
} from "../../src/features/installed-feature-contracts.ts";
|
} from "../../src/features/installed-feature-contracts.ts";
|
||||||
|
|
||||||
const COMPILED = Object.freeze([
|
const COMPILED = Object.freeze([
|
||||||
Object.freeze({ featureId: "reference-feature" }),
|
Object.freeze({ featureId: "alpha-feature" }),
|
||||||
Object.freeze({ featureId: "billing" }),
|
Object.freeze({ featureId: "billing" }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ describe("build-time product selection", () => {
|
|||||||
expect(
|
expect(
|
||||||
selectCompiledProductFeatures(COMPILED, declared).map((f) => f.featureId),
|
selectCompiledProductFeatures(COMPILED, declared).map((f) => f.featureId),
|
||||||
String(declared),
|
String(declared),
|
||||||
).toEqual(["reference-feature", "billing"]);
|
).toEqual(["alpha-feature", "billing"]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,10 +35,10 @@ describe("build-time product selection", () => {
|
|||||||
selectCompiledProductFeatures(COMPILED, "billing").map((f) => f.featureId),
|
selectCompiledProductFeatures(COMPILED, "billing").map((f) => f.featureId),
|
||||||
).toEqual(["billing"]);
|
).toEqual(["billing"]);
|
||||||
expect(
|
expect(
|
||||||
selectCompiledProductFeatures(COMPILED, " billing , reference-feature ").map(
|
selectCompiledProductFeatures(COMPILED, " billing , alpha-feature ").map(
|
||||||
(f) => f.featureId,
|
(f) => f.featureId,
|
||||||
),
|
),
|
||||||
).toEqual(["reference-feature", "billing"]);
|
).toEqual(["alpha-feature", "billing"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("selects nothing only when asked explicitly", () => {
|
it("selects nothing only when asked explicitly", () => {
|
||||||
@@ -79,13 +79,13 @@ describe("build-time product selection", () => {
|
|||||||
describe("runtime product feature resolution", () => {
|
describe("runtime product feature resolution", () => {
|
||||||
it("reports active, disabled and not-installed distinctly", () => {
|
it("reports active, disabled and not-installed distinctly", () => {
|
||||||
const statuses = resolveProductFeatures(
|
const statuses = resolveProductFeatures(
|
||||||
["reference-feature", "billing"],
|
["alpha-feature", "billing"],
|
||||||
["reference-feature"],
|
["alpha-feature"],
|
||||||
{ "reference-feature": "DISABLED" },
|
{ "alpha-feature": "DISABLED" },
|
||||||
);
|
);
|
||||||
expect(statuses).toEqual([
|
expect(statuses).toEqual([
|
||||||
|
{ featureId: "alpha-feature", state: "DISABLED_BY_CONFIG" },
|
||||||
{ featureId: "billing", state: "NOT_INSTALLED" },
|
{ featureId: "billing", state: "NOT_INSTALLED" },
|
||||||
{ featureId: "reference-feature", state: "DISABLED_BY_CONFIG" },
|
|
||||||
]);
|
]);
|
||||||
expect(activeProductFeatureIds(statuses)).toEqual([]);
|
expect(activeProductFeatureIds(statuses)).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -103,11 +103,11 @@ describe("runtime product feature resolution", () => {
|
|||||||
// A shared runtime document may cover several builds, so a stale key is
|
// A shared runtime document may cover several builds, so a stale key is
|
||||||
// inert rather than fatal.
|
// inert rather than fatal.
|
||||||
const statuses = resolveProductFeatures(
|
const statuses = resolveProductFeatures(
|
||||||
["reference-feature"],
|
["alpha-feature"],
|
||||||
["reference-feature"],
|
["alpha-feature"],
|
||||||
{ analytics: "DISABLED" },
|
{ analytics: "DISABLED" },
|
||||||
);
|
);
|
||||||
expect(activeProductFeatureIds(statuses)).toEqual(["reference-feature"]);
|
expect(activeProductFeatureIds(statuses)).toEqual(["alpha-feature"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves an installed feature active without an override", () => {
|
it("leaves an installed feature active without an override", () => {
|
||||||
@@ -140,15 +140,15 @@ describe("runtime config carries the switch", () => {
|
|||||||
expect(
|
expect(
|
||||||
runtimeConfigV2ArtifactSchema.parse({
|
runtimeConfigV2ArtifactSchema.parse({
|
||||||
...base,
|
...base,
|
||||||
FEATURE_OVERRIDES: { "reference-feature": "DISABLED" },
|
FEATURE_OVERRIDES: { "alpha-feature": "DISABLED" },
|
||||||
}).FEATURE_OVERRIDES,
|
}).FEATURE_OVERRIDES,
|
||||||
).toEqual({ "reference-feature": "DISABLED" });
|
).toEqual({ "alpha-feature": "DISABLED" });
|
||||||
// There is no "ENABLED": the vocabulary itself is what makes the rule
|
// There is no "ENABLED": the vocabulary itself is what makes the rule
|
||||||
// unbreakable, not a check somewhere downstream.
|
// unbreakable, not a check somewhere downstream.
|
||||||
expect(
|
expect(
|
||||||
runtimeConfigV2ArtifactSchema.safeParse({
|
runtimeConfigV2ArtifactSchema.safeParse({
|
||||||
...base,
|
...base,
|
||||||
FEATURE_OVERRIDES: { "reference-feature": "ENABLED" },
|
FEATURE_OVERRIDES: { "alpha-feature": "ENABLED" },
|
||||||
}).success,
|
}).success,
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -212,11 +212,13 @@ describe("route ownership", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("owns exactly the routes the registry received from features", () => {
|
it("owns exactly routes that exist in the composed registry", () => {
|
||||||
const owned = Object.keys(ROUTE_FEATURE_OWNER);
|
const owned = Object.keys(ROUTE_FEATURE_OWNER);
|
||||||
expect(owned.length).toBeGreaterThan(0);
|
|
||||||
for (const routeId of owned) {
|
for (const routeId of owned) {
|
||||||
expect(Object.keys(ROUTE_REGISTRY), routeId).toContain(routeId);
|
expect(Object.keys(ROUTE_REGISTRY), routeId).toContain(routeId);
|
||||||
}
|
}
|
||||||
|
expect(
|
||||||
|
owned.every((routeId) => ROUTE_FEATURE_OWNER[routeId] !== undefined),
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
type PublicCacheRuntimePolicy,
|
||||||
} from "../../src/adapters/cache-storage/public-cache-policy.ts";
|
} from "../../src/adapters/cache-storage/public-cache-policy.ts";
|
||||||
import {
|
import {
|
||||||
computePublicCacheManifestDigestHex,
|
|
||||||
createPublicResponseCacheAdapter,
|
createPublicResponseCacheAdapter,
|
||||||
type PublicCacheMutationLock,
|
type PublicCacheMutationLock,
|
||||||
} from "../../src/adapters/cache-storage/public-response-cache-adapter.ts";
|
} from "../../src/adapters/cache-storage/public-response-cache-adapter.ts";
|
||||||
|
|
||||||
class MemoryCache {
|
import {
|
||||||
readonly responses: Array<Readonly<{
|
MemoryCacheStorage,
|
||||||
request: Request;
|
deferred,
|
||||||
response: Response;
|
digestHex,
|
||||||
}>> = [];
|
immediateLock,
|
||||||
|
manifestFor,
|
||||||
async match(request: RequestInfo | URL): Promise<Response | undefined> {
|
} from "./public-response-cache-fixture.ts";
|
||||||
const url =
|
|
||||||
request instanceof Request ? request.url : new URL(String(request)).href;
|
|
||||||
const nativeRequest =
|
|
||||||
request instanceof Request ? request : new Request(url);
|
|
||||||
return this.responses
|
|
||||||
.find(
|
|
||||||
(entry) =>
|
|
||||||
entry.request.url === url &&
|
|
||||||
varyMatches(entry.request, nativeRequest, entry.response),
|
|
||||||
)
|
|
||||||
?.response.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
async put(request: RequestInfo | URL, response: Response): Promise<void> {
|
|
||||||
const url =
|
|
||||||
request instanceof Request ? request.url : new URL(String(request)).href;
|
|
||||||
const nativeRequest =
|
|
||||||
request instanceof Request ? request.clone() : new Request(url);
|
|
||||||
const existing = this.responses.findIndex(
|
|
||||||
(entry) =>
|
|
||||||
entry.request.url === url &&
|
|
||||||
varyMatches(entry.request, nativeRequest, response),
|
|
||||||
);
|
|
||||||
const entry = {
|
|
||||||
request: nativeRequest,
|
|
||||||
response: response.clone(),
|
|
||||||
};
|
|
||||||
if (existing >= 0) this.responses.splice(existing, 1, entry);
|
|
||||||
else this.responses.push(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function varyMatches(
|
|
||||||
storedRequest: Request,
|
|
||||||
incomingRequest: Request,
|
|
||||||
response: Response,
|
|
||||||
): boolean {
|
|
||||||
const vary = response.headers.get("vary");
|
|
||||||
if (!vary) return true;
|
|
||||||
return vary
|
|
||||||
.split(",")
|
|
||||||
.map((name) => name.trim().toLowerCase())
|
|
||||||
.every(
|
|
||||||
(name) =>
|
|
||||||
storedRequest.headers.get(name) ===
|
|
||||||
incomingRequest.headers.get(name),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class MemoryCacheStorage {
|
|
||||||
readonly caches = new Map<string, MemoryCache>();
|
|
||||||
|
|
||||||
async open(name: string): Promise<Cache> {
|
|
||||||
let cache = this.caches.get(name);
|
|
||||||
if (!cache) {
|
|
||||||
cache = new MemoryCache();
|
|
||||||
this.caches.set(name, cache);
|
|
||||||
}
|
|
||||||
return cache as unknown as Cache;
|
|
||||||
}
|
|
||||||
|
|
||||||
async keys(): Promise<string[]> {
|
|
||||||
return [...this.caches.keys()];
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(name: string): Promise<boolean> {
|
|
||||||
return this.caches.delete(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const immediateLock: PublicCacheMutationLock = {
|
|
||||||
async run(_signal, task) {
|
|
||||||
return await task();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function deferred<Value>() {
|
|
||||||
let settle: ((value: Value) => void) | undefined;
|
|
||||||
const promise = new Promise<Value>((resolve) => {
|
|
||||||
settle = resolve;
|
|
||||||
});
|
|
||||||
return Object.freeze({
|
|
||||||
promise,
|
|
||||||
resolve(value: Value): void {
|
|
||||||
settle?.(value);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function digestHex(bytes: Uint8Array): Promise<string> {
|
|
||||||
const digest = await globalThis.crypto.subtle.digest(
|
|
||||||
"SHA-256",
|
|
||||||
Uint8Array.from(bytes),
|
|
||||||
);
|
|
||||||
return [...new Uint8Array(digest)]
|
|
||||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function manifestFor(
|
|
||||||
releaseRegistryId: string,
|
|
||||||
assets: readonly PublicCacheAsset[],
|
|
||||||
policy: PublicCacheRuntimePolicy = createDefaultPublicCachePolicy(
|
|
||||||
"https://assets.example.test",
|
|
||||||
),
|
|
||||||
): Promise<PublicCacheReleaseManifest> {
|
|
||||||
return {
|
|
||||||
releaseRegistryId,
|
|
||||||
assets,
|
|
||||||
manifestDigestHex: await computePublicCacheManifestDigestHex(
|
|
||||||
globalThis.crypto,
|
|
||||||
releaseRegistryId,
|
|
||||||
assets,
|
|
||||||
policy,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("public response Cache Storage adapter", () => {
|
describe("public response Cache Storage adapter", () => {
|
||||||
it("rejects a policy that enables variants but strips Vary", () => {
|
it("rejects a policy that enables variants but strips Vary", () => {
|
||||||
@@ -1513,199 +1395,3 @@ describe("public response Cache Storage adapter", () => {
|
|||||||
expect(await cacheStorage.keys()).toEqual([]);
|
expect(await cacheStorage.keys()).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* STO-RR-04 / STO-RR-05. A release cache that is currently serving traffic is
|
|
||||||
* the last thing a repair may destroy. A transient marker read failure is not
|
|
||||||
* evidence of damage, and a repair that has not yet fetched anything has not
|
|
||||||
* yet earned the right to delete what still works.
|
|
||||||
*/
|
|
||||||
describe("public response cache repair is failure-atomic", () => {
|
|
||||||
async function stagedRelease(releaseRegistryId: string) {
|
|
||||||
const policy = createDefaultPublicCachePolicy(
|
|
||||||
"https://assets.example.test",
|
|
||||||
);
|
|
||||||
const firstBytes = new Uint8Array([1, 1, 1, 1]);
|
|
||||||
const secondBytes = new Uint8Array([2, 2, 2, 2]);
|
|
||||||
const assets: readonly PublicCacheAsset[] = [
|
|
||||||
{
|
|
||||||
absoluteUrl: "https://assets.example.test/first.js",
|
|
||||||
expectedByteLength: firstBytes.byteLength,
|
|
||||||
expectedContentType: "application/javascript",
|
|
||||||
integrity: {
|
|
||||||
algorithm: "SHA-256",
|
|
||||||
digestHex: await digestHex(firstBytes),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
absoluteUrl: "https://assets.example.test/second.js",
|
|
||||||
expectedByteLength: secondBytes.byteLength,
|
|
||||||
expectedContentType: "application/javascript",
|
|
||||||
integrity: {
|
|
||||||
algorithm: "SHA-256",
|
|
||||||
digestHex: await digestHex(secondBytes),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const bodies = new Map<string, Uint8Array>([
|
|
||||||
[assets[0]!.absoluteUrl, firstBytes],
|
|
||||||
[assets[1]!.absoluteUrl, secondBytes],
|
|
||||||
]);
|
|
||||||
const cacheStorage = new MemoryCacheStorage();
|
|
||||||
const fetchLog: string[] = [];
|
|
||||||
let failFrom: string | null = null;
|
|
||||||
const adapter = createPublicResponseCacheAdapter({
|
|
||||||
cacheStorage: cacheStorage as unknown as CacheStorage,
|
|
||||||
crypto: globalThis.crypto,
|
|
||||||
mutationLock: immediateLock,
|
|
||||||
policy,
|
|
||||||
fetcher: async (request: Request) => {
|
|
||||||
fetchLog.push(request.url);
|
|
||||||
if (failFrom !== null && request.url === failFrom) {
|
|
||||||
throw new TypeError("network is down");
|
|
||||||
}
|
|
||||||
const body = bodies.get(request.url);
|
|
||||||
if (!body) throw new TypeError(`unknown asset ${request.url}`);
|
|
||||||
return new Response(Uint8Array.from(body), {
|
|
||||||
headers: {
|
|
||||||
"cache-control": "public",
|
|
||||||
"content-type": "application/javascript",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const manifest = await manifestFor(releaseRegistryId, assets, policy);
|
|
||||||
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
|
|
||||||
ok: true,
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
await adapter.admin.activateRelease(
|
|
||||||
manifest.releaseRegistryId,
|
|
||||||
manifest.manifestDigestHex,
|
|
||||||
),
|
|
||||||
).toMatchObject({ ok: true });
|
|
||||||
const cacheName = [...cacheStorage.caches.keys()].find((name) =>
|
|
||||||
name.includes(releaseRegistryId),
|
|
||||||
);
|
|
||||||
if (!cacheName) throw new Error("staged cache missing");
|
|
||||||
|
|
||||||
return {
|
|
||||||
adapter,
|
|
||||||
assets,
|
|
||||||
cacheName,
|
|
||||||
cacheStorage,
|
|
||||||
fetchLog,
|
|
||||||
manifest,
|
|
||||||
setFailure(url: string | null) {
|
|
||||||
failFrom = url;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
it("does not delete an active candidate when the marker read fails transiently", async () => {
|
|
||||||
const release = await stagedRelease("transient-marker");
|
|
||||||
const cache = release.cacheStorage.caches.get(release.cacheName)!;
|
|
||||||
const realMatch = cache.match.bind(cache);
|
|
||||||
let markerReads = 0;
|
|
||||||
const assetUrls = new Set(release.assets.map((asset) => asset.absoluteUrl));
|
|
||||||
cache.match = async (request: RequestInfo | URL) => {
|
|
||||||
const url =
|
|
||||||
request instanceof Request ? request.url : String(request);
|
|
||||||
if (!assetUrls.has(url)) {
|
|
||||||
markerReads += 1;
|
|
||||||
throw new DOMException("Storage is busy", "InvalidStateError");
|
|
||||||
}
|
|
||||||
return await realMatch(request);
|
|
||||||
};
|
|
||||||
|
|
||||||
const restaged = await release.adapter.admin.stageRelease(release.manifest);
|
|
||||||
|
|
||||||
expect(markerReads).toBeGreaterThan(0);
|
|
||||||
expect(restaged.ok).toBe(false);
|
|
||||||
expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true);
|
|
||||||
cache.match = realMatch;
|
|
||||||
expect(
|
|
||||||
await release.adapter.responses.matchActiveExact({
|
|
||||||
absoluteUrl: release.assets[0]!.absoluteUrl,
|
|
||||||
}),
|
|
||||||
).toMatchObject({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps every healthy asset when one repair fetch fails", async () => {
|
|
||||||
const release = await stagedRelease("partial-repair");
|
|
||||||
const cache = release.cacheStorage.caches.get(release.cacheName)!;
|
|
||||||
// Corrupt only the first asset's stored bytes.
|
|
||||||
const corrupted = cache.responses.findIndex(
|
|
||||||
(entry) => entry.request.url === release.assets[0]!.absoluteUrl,
|
|
||||||
);
|
|
||||||
expect(corrupted).toBeGreaterThanOrEqual(0);
|
|
||||||
cache.responses.splice(corrupted, 1);
|
|
||||||
|
|
||||||
release.setFailure(release.assets[0]!.absoluteUrl);
|
|
||||||
const restaged = await release.adapter.admin.stageRelease(release.manifest);
|
|
||||||
expect(restaged.ok).toBe(false);
|
|
||||||
|
|
||||||
// The cache still exists and the healthy asset is still served.
|
|
||||||
expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true);
|
|
||||||
expect(
|
|
||||||
await release.adapter.responses.matchActiveExact({
|
|
||||||
absoluteUrl: release.assets[1]!.absoluteUrl,
|
|
||||||
}),
|
|
||||||
).toMatchObject({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still removes a candidate this call created when staging fails", async () => {
|
|
||||||
const policy = createDefaultPublicCachePolicy(
|
|
||||||
"https://assets.example.test",
|
|
||||||
);
|
|
||||||
const bytes = new Uint8Array([7, 7, 7, 7]);
|
|
||||||
const asset: PublicCacheAsset = {
|
|
||||||
absoluteUrl: "https://assets.example.test/fresh.js",
|
|
||||||
expectedByteLength: bytes.byteLength,
|
|
||||||
expectedContentType: "application/javascript",
|
|
||||||
integrity: {
|
|
||||||
algorithm: "SHA-256",
|
|
||||||
digestHex: await digestHex(bytes),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const cacheStorage = new MemoryCacheStorage();
|
|
||||||
const adapter = createPublicResponseCacheAdapter({
|
|
||||||
cacheStorage: cacheStorage as unknown as CacheStorage,
|
|
||||||
crypto: globalThis.crypto,
|
|
||||||
mutationLock: immediateLock,
|
|
||||||
policy,
|
|
||||||
fetcher: async () => {
|
|
||||||
throw new TypeError("network is down");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const manifest = await manifestFor("fresh-release", [asset], policy);
|
|
||||||
|
|
||||||
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
|
|
||||||
ok: false,
|
|
||||||
});
|
|
||||||
expect(await cacheStorage.keys()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("repairs an evicted asset in place and keeps the release usable", async () => {
|
|
||||||
const release = await stagedRelease("in-place-repair");
|
|
||||||
const cache = release.cacheStorage.caches.get(release.cacheName)!;
|
|
||||||
const evicted = cache.responses.findIndex(
|
|
||||||
(entry) => entry.request.url === release.assets[1]!.absoluteUrl,
|
|
||||||
);
|
|
||||||
cache.responses.splice(evicted, 1);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await release.adapter.admin.stageRelease(release.manifest),
|
|
||||||
).toMatchObject({ ok: true });
|
|
||||||
expect(
|
|
||||||
await release.adapter.responses.matchActiveExact({
|
|
||||||
absoluteUrl: release.assets[1]!.absoluteUrl,
|
|
||||||
}),
|
|
||||||
).toMatchObject({ ok: true });
|
|
||||||
expect(
|
|
||||||
await release.adapter.responses.matchActiveExact({
|
|
||||||
absoluteUrl: release.assets[0]!.absoluteUrl,
|
|
||||||
}),
|
|
||||||
).toMatchObject({ ok: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ResumableUploadCheckpoint,
|
||||||
|
ResumableUploadCheckpointStore,
|
||||||
|
ResumableUploadControlPlane,
|
||||||
|
ResumableUploadSource,
|
||||||
|
UploadPartExecutor,
|
||||||
|
UploadPartReceipt,
|
||||||
|
UploadProviderResult,
|
||||||
|
UploadSession,
|
||||||
|
} from "../../src/application/ports/browser-transfer/resumable-upload.ts";
|
||||||
|
import { RESUMABLE_UPLOAD_PROTOCOL } from "../../src/application/ports/browser-transfer/resumable-upload.ts";
|
||||||
|
import type {
|
||||||
|
BrowserDataResult,
|
||||||
|
} from "../../src/application/ports/browser-file-storage/shared.ts";
|
||||||
|
import {
|
||||||
|
browserDataFailure,
|
||||||
|
browserDataSuccess,
|
||||||
|
} from "../../src/adapters/browser-file-storage/result.ts";
|
||||||
|
import { resolveResumableUploadRuntimePolicy } from "../../src/adapters/browser-transfer/resumable-upload/runtime-policy.ts";
|
||||||
|
import type {
|
||||||
|
UploadCancellationChannel,
|
||||||
|
UploadCancellationListener,
|
||||||
|
} from "../../src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts";
|
||||||
|
import type { UploadMutationLock } from "../../src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts";
|
||||||
|
|
||||||
|
export type TestCapability = Readonly<{ id: string }>;
|
||||||
|
|
||||||
|
export const activeSignal = new AbortController().signal;
|
||||||
|
export const noContentionLock: UploadMutationLock = Object.freeze({
|
||||||
|
async run<Value>(
|
||||||
|
_uploadKey: string,
|
||||||
|
_signal: AbortSignal,
|
||||||
|
task: () => Promise<Value>,
|
||||||
|
): Promise<Value> {
|
||||||
|
return await task();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function createSerialMutationLock(): UploadMutationLock {
|
||||||
|
let tail = Promise.resolve();
|
||||||
|
return Object.freeze({
|
||||||
|
run<Value>(
|
||||||
|
_uploadKey: string,
|
||||||
|
signal: AbortSignal,
|
||||||
|
task: () => Promise<Value>,
|
||||||
|
): Promise<Value> {
|
||||||
|
const result = tail.then(async () => {
|
||||||
|
if (signal.aborted) {
|
||||||
|
throw new DOMException(
|
||||||
|
"The operation was aborted.",
|
||||||
|
"AbortError",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return await task();
|
||||||
|
});
|
||||||
|
tail = result.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMemoryCancellationPair(): readonly [
|
||||||
|
UploadCancellationChannel,
|
||||||
|
UploadCancellationChannel,
|
||||||
|
] {
|
||||||
|
const listeners = [
|
||||||
|
new Set<UploadCancellationListener>(),
|
||||||
|
new Set<UploadCancellationListener>(),
|
||||||
|
] as const;
|
||||||
|
const channels = listeners.map((ownListeners, ownIndex) => {
|
||||||
|
let closed = false;
|
||||||
|
return Object.freeze({
|
||||||
|
publish(uploadKey: string) {
|
||||||
|
if (closed) return false;
|
||||||
|
for (const [index, peerListeners] of listeners.entries()) {
|
||||||
|
if (index === ownIndex) continue;
|
||||||
|
for (const listener of [...peerListeners]) {
|
||||||
|
listener(uploadKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
subscribe(listener: UploadCancellationListener) {
|
||||||
|
if (closed) throw new TypeError("closed");
|
||||||
|
ownListeners.add(listener);
|
||||||
|
return () => ownListeners.delete(listener);
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
closed = true;
|
||||||
|
ownListeners.clear();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return channels as unknown as readonly [
|
||||||
|
UploadCancellationChannel,
|
||||||
|
UploadCancellationChannel,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MemoryCheckpointStore implements ResumableUploadCheckpointStore {
|
||||||
|
readonly rows = new Map<string, ResumableUploadCheckpoint>();
|
||||||
|
closed = false;
|
||||||
|
|
||||||
|
async read(
|
||||||
|
uploadKey: string,
|
||||||
|
): Promise<BrowserDataResult<ResumableUploadCheckpoint | null>> {
|
||||||
|
return browserDataSuccess(
|
||||||
|
structuredClone(this.rows.get(uploadKey) ?? null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async compareAndSwap(
|
||||||
|
input: Parameters<
|
||||||
|
ResumableUploadCheckpointStore["compareAndSwap"]
|
||||||
|
>[0],
|
||||||
|
): Promise<BrowserDataResult<ResumableUploadCheckpoint>> {
|
||||||
|
const current = this.rows.get(input.checkpoint.uploadKey);
|
||||||
|
if (
|
||||||
|
(input.expectedRevision === null && current) ||
|
||||||
|
(input.expectedRevision !== null &&
|
||||||
|
current?.revision !== input.expectedRevision)
|
||||||
|
) {
|
||||||
|
return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", {
|
||||||
|
recovery: "RECONCILE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const snapshot = structuredClone(input.checkpoint);
|
||||||
|
this.rows.set(snapshot.uploadKey, snapshot);
|
||||||
|
return browserDataSuccess(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(
|
||||||
|
input: Parameters<ResumableUploadCheckpointStore["remove"]>[0],
|
||||||
|
): Promise<BrowserDataResult<void>> {
|
||||||
|
const current = this.rows.get(input.uploadKey);
|
||||||
|
if (current?.revision !== input.expectedRevision) {
|
||||||
|
return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", {
|
||||||
|
recovery: "RECONCILE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.rows.delete(input.uploadKey);
|
||||||
|
return browserDataSuccess(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.closed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rangeSource(bytes: Uint8Array): ResumableUploadSource {
|
||||||
|
return Object.freeze({
|
||||||
|
kind: "RANGE_READER" as const,
|
||||||
|
reader: Object.freeze({
|
||||||
|
byteLength: bytes.byteLength,
|
||||||
|
async readRange(input: Readonly<{
|
||||||
|
offset: number;
|
||||||
|
length: number;
|
||||||
|
signal: AbortSignal;
|
||||||
|
}>) {
|
||||||
|
if (input.signal.aborted) {
|
||||||
|
return browserDataFailure("ABORTED", "FILE_READ");
|
||||||
|
}
|
||||||
|
return browserDataSuccess(
|
||||||
|
bytes.slice(input.offset, input.offset + input.length),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function byteStreamSource(bytes: Uint8Array): ResumableUploadSource {
|
||||||
|
return Object.freeze({
|
||||||
|
kind: "FILE_BYTE_SOURCE" as const,
|
||||||
|
bytes: Object.freeze({
|
||||||
|
byteLength: bytes.byteLength,
|
||||||
|
async *stream(signal: AbortSignal) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
yield browserDataFailure("ABORTED", "FILE_READ");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
yield browserDataSuccess(bytes.slice(0, 3));
|
||||||
|
yield browserDataSuccess(bytes.slice(3));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runtimePolicy(
|
||||||
|
overrides: Partial<
|
||||||
|
Parameters<typeof resolveResumableUploadRuntimePolicy>[0]
|
||||||
|
> = {},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
partSizeBytes: 4,
|
||||||
|
maxFileBytes: 100,
|
||||||
|
maxPartCount: 25,
|
||||||
|
maxConcurrency: 3,
|
||||||
|
maxInFlightBytes: 48,
|
||||||
|
partBufferCopyFactor: 4,
|
||||||
|
maxSourceChunkBytes: 8,
|
||||||
|
maxRetries: 2,
|
||||||
|
retryBaseDelayMs: 1,
|
||||||
|
retryMaxDelayMs: 10,
|
||||||
|
maxRetryAfterMs: 100,
|
||||||
|
capabilityRefreshSkewMs: 5,
|
||||||
|
maxSessionLifetimeMs: 10_000,
|
||||||
|
providerAttemptTimeoutMs: 100,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ControlHarness = Readonly<{
|
||||||
|
control: ResumableUploadControlPlane<TestCapability>;
|
||||||
|
accepted: Map<number, UploadPartReceipt>;
|
||||||
|
issued: ReturnType<typeof vi.fn>;
|
||||||
|
completedParts: UploadPartReceipt[][];
|
||||||
|
getSession(): UploadSession | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function createControlHarness(options: Readonly<{
|
||||||
|
now?: number;
|
||||||
|
serverMaxConcurrency?: number;
|
||||||
|
sessionId?: (createIndex: number) => string;
|
||||||
|
statusParts?: (
|
||||||
|
session: UploadSession,
|
||||||
|
accepted: Map<number, UploadPartReceipt>,
|
||||||
|
) => readonly UploadPartReceipt[];
|
||||||
|
issueCapability?: (
|
||||||
|
input: Parameters<
|
||||||
|
ResumableUploadControlPlane<TestCapability>["issuePartCapability"]
|
||||||
|
>[0],
|
||||||
|
callIndex: number,
|
||||||
|
) => UploadProviderResult<Readonly<{
|
||||||
|
capability: TestCapability;
|
||||||
|
uploadBindingSha256: string;
|
||||||
|
expiresAtEpochMs: number;
|
||||||
|
}>>;
|
||||||
|
}> = {}): ControlHarness {
|
||||||
|
const now = options.now ?? 1_000;
|
||||||
|
const accepted = new Map<number, UploadPartReceipt>();
|
||||||
|
const completedParts: UploadPartReceipt[][] = [];
|
||||||
|
let session: UploadSession | null = null;
|
||||||
|
let createCount = 0;
|
||||||
|
let issueCount = 0;
|
||||||
|
const issued = vi.fn();
|
||||||
|
const control: ResumableUploadControlPlane<TestCapability> = {
|
||||||
|
async createSession(input) {
|
||||||
|
createCount += 1;
|
||||||
|
session = Object.freeze({
|
||||||
|
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
||||||
|
sessionId:
|
||||||
|
options.sessionId?.(createCount) ?? "session_01",
|
||||||
|
requestBindingSha256: input.requestBindingSha256,
|
||||||
|
fingerprint: input.fingerprint,
|
||||||
|
partSizeBytes: input.requestedPartSizeBytes,
|
||||||
|
partCount: input.fingerprint.partCount,
|
||||||
|
maxConcurrency: options.serverMaxConcurrency ?? 2,
|
||||||
|
expiresAtEpochMs: now + 5_000,
|
||||||
|
});
|
||||||
|
return browserDataSuccess(session);
|
||||||
|
},
|
||||||
|
async getStatus() {
|
||||||
|
if (!session) {
|
||||||
|
return browserDataFailure("NOT_FOUND", "UPLOAD_RECONCILE");
|
||||||
|
}
|
||||||
|
const parts =
|
||||||
|
options.statusParts?.(session, accepted) ??
|
||||||
|
[...accepted.values()].sort(
|
||||||
|
(left, right) => left.partNumber - right.partNumber,
|
||||||
|
);
|
||||||
|
return browserDataSuccess({
|
||||||
|
state: "ACTIVE",
|
||||||
|
session,
|
||||||
|
acceptedParts: parts,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async issuePartCapability(input) {
|
||||||
|
issueCount += 1;
|
||||||
|
issued(input);
|
||||||
|
return (
|
||||||
|
options.issueCapability?.(input, issueCount) ??
|
||||||
|
browserDataSuccess({
|
||||||
|
capability: Object.freeze({ id: `cap-${issueCount}` }),
|
||||||
|
uploadBindingSha256: input.uploadBindingSha256,
|
||||||
|
expiresAtEpochMs: now + 4_000,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async complete(input) {
|
||||||
|
completedParts.push([...input.orderedParts]);
|
||||||
|
return browserDataSuccess({
|
||||||
|
state: "QUARANTINED",
|
||||||
|
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
requestBindingSha256: input.requestBindingSha256,
|
||||||
|
fingerprint: input.fingerprint,
|
||||||
|
resourceId: "resource_01",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async abort() {
|
||||||
|
return browserDataSuccess({ state: "ABORTED" });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
control,
|
||||||
|
accepted,
|
||||||
|
issued,
|
||||||
|
completedParts,
|
||||||
|
getSession: () => session,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executorFor(
|
||||||
|
harness: ControlHarness,
|
||||||
|
options: Readonly<{
|
||||||
|
delay?: () => Promise<void>;
|
||||||
|
onActive?: (active: number) => void;
|
||||||
|
}> = {},
|
||||||
|
): UploadPartExecutor<TestCapability> {
|
||||||
|
let active = 0;
|
||||||
|
return {
|
||||||
|
async uploadPart(input) {
|
||||||
|
active += 1;
|
||||||
|
options.onActive?.(active);
|
||||||
|
await options.delay?.();
|
||||||
|
active -= 1;
|
||||||
|
const receipt = Object.freeze({
|
||||||
|
...input.part,
|
||||||
|
receiptToken: `etag-part-${input.part.partNumber}`,
|
||||||
|
});
|
||||||
|
harness.accepted.set(input.part.partNumber, receipt);
|
||||||
|
return browserDataSuccess(receipt);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { browserDataFailure } from "../../src/adapters/browser-file-storage/result.ts";
|
||||||
|
import { createResumableUploadRuntime } from "../../src/adapters/browser-transfer/resumable-upload/resumable-upload-runtime.ts";
|
||||||
|
import {
|
||||||
|
activeSignal,
|
||||||
|
byteStreamSource,
|
||||||
|
createControlHarness,
|
||||||
|
executorFor,
|
||||||
|
MemoryCheckpointStore,
|
||||||
|
noContentionLock,
|
||||||
|
runtimePolicy,
|
||||||
|
} from "./resumable-upload-runtime-fixture.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TR-RR-06. A non-cooperative mutation lock or provider must not make teardown
|
||||||
|
* unbounded: `dispose()` bounds its drain and reports honestly when the runtime
|
||||||
|
* is still CLOSING, and an abort is admitted physical work it cannot step over.
|
||||||
|
*/
|
||||||
|
describe("TR-RR-06 bounded resumable teardown", () => {
|
||||||
|
it("reports an unproved drain instead of waiting forever", async () => {
|
||||||
|
const checkpoints = new MemoryCheckpointStore();
|
||||||
|
const harness = createControlHarness();
|
||||||
|
const runtime = createResumableUploadRuntime({
|
||||||
|
controlPlane: harness.control,
|
||||||
|
partExecutor: executorFor(harness, { delay: async () => {} }),
|
||||||
|
checkpoints,
|
||||||
|
// A lock that never grants: dispose must still be bounded.
|
||||||
|
mutationLock: Object.freeze({
|
||||||
|
async run<Value>(): Promise<Value> {
|
||||||
|
return await new Promise<never>(() => {});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
crypto,
|
||||||
|
policy: runtimePolicy({ cleanupDeadlineMs: 20 }),
|
||||||
|
now: () => 1_000,
|
||||||
|
random: () => 0,
|
||||||
|
sleep: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
void runtime.upload({
|
||||||
|
uploadKey: "upload_key_hung",
|
||||||
|
purpose: "attachment",
|
||||||
|
mediaType: "application/octet-stream",
|
||||||
|
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
|
||||||
|
signal: activeSignal,
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const disposed = await runtime.dispose();
|
||||||
|
expect(disposed.ok).toBe(false);
|
||||||
|
// Still CLOSING: physical work the caller must not treat as finished.
|
||||||
|
expect(runtime.lifecycle()).toBe("CLOSING");
|
||||||
|
expect(checkpoints.closed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes once every admitted operation settles", async () => {
|
||||||
|
const checkpoints = new MemoryCheckpointStore();
|
||||||
|
const harness = createControlHarness();
|
||||||
|
const runtime = createResumableUploadRuntime({
|
||||||
|
controlPlane: harness.control,
|
||||||
|
partExecutor: executorFor(harness, { delay: async () => {} }),
|
||||||
|
checkpoints,
|
||||||
|
mutationLock: noContentionLock,
|
||||||
|
crypto,
|
||||||
|
policy: runtimePolicy({ cleanupDeadlineMs: 200 }),
|
||||||
|
now: () => 1_000,
|
||||||
|
random: () => 0,
|
||||||
|
sleep: async () => {},
|
||||||
|
});
|
||||||
|
const disposed = await runtime.dispose();
|
||||||
|
expect(disposed).toMatchObject({ ok: true });
|
||||||
|
expect(runtime.lifecycle()).toBe("CLOSED");
|
||||||
|
expect(checkpoints.closed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TR-04. `dispose()` proved quiescence from the wrapper registry only. A
|
||||||
|
* provider that ignored its attempt deadline let the wrapper settle first and
|
||||||
|
* leave the set empty, so teardown reported a drained runtime — and closed the
|
||||||
|
* checkpoint store — while the provider was still running.
|
||||||
|
*/
|
||||||
|
describe("TR-04 teardown waits for raw provider work, not only its wrapper", () => {
|
||||||
|
it("refuses to report a drained runtime while a provider is still running", async () => {
|
||||||
|
const harness = createControlHarness();
|
||||||
|
const checkpoints = new MemoryCheckpointStore();
|
||||||
|
const closeStore = vi.spyOn(checkpoints, "close");
|
||||||
|
let releaseProvider: (() => void) | undefined;
|
||||||
|
// Ignores the attempt signal entirely and outlives its own deadline.
|
||||||
|
harness.control.createSession = () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
releaseProvider = () =>
|
||||||
|
resolve(
|
||||||
|
browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", {
|
||||||
|
retryable: true,
|
||||||
|
recovery: "RESUME",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = createResumableUploadRuntime({
|
||||||
|
controlPlane: harness.control,
|
||||||
|
partExecutor: executorFor(harness),
|
||||||
|
checkpoints,
|
||||||
|
mutationLock: noContentionLock,
|
||||||
|
crypto,
|
||||||
|
policy: runtimePolicy({
|
||||||
|
providerAttemptTimeoutMs: 5,
|
||||||
|
cleanupDeadlineMs: 25,
|
||||||
|
maxRetries: 0,
|
||||||
|
}),
|
||||||
|
now: () => 1_000,
|
||||||
|
random: () => 0,
|
||||||
|
sleep: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploading = runtime.upload({
|
||||||
|
uploadKey: "upload_key_raw",
|
||||||
|
purpose: "attachment",
|
||||||
|
mediaType: "application/octet-stream",
|
||||||
|
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
|
||||||
|
signal: activeSignal,
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(releaseProvider).toBeDefined());
|
||||||
|
// The wrapper has already given up on the attempt.
|
||||||
|
await uploading;
|
||||||
|
|
||||||
|
const disposed = await runtime.dispose();
|
||||||
|
expect(disposed.ok).toBe(false);
|
||||||
|
if (!disposed.ok) {
|
||||||
|
expect(disposed.error.code).toBe("UNAVAILABLE");
|
||||||
|
expect(disposed.error.recovery).toBe("RESUME");
|
||||||
|
}
|
||||||
|
// The store stays open while something could still write a checkpoint.
|
||||||
|
expect(closeStore).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
releaseProvider?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a drained runtime once the raw provider settles", async () => {
|
||||||
|
const harness = createControlHarness();
|
||||||
|
const checkpoints = new MemoryCheckpointStore();
|
||||||
|
let releaseProvider: (() => void) | undefined;
|
||||||
|
harness.control.createSession = () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
releaseProvider = () =>
|
||||||
|
resolve(
|
||||||
|
browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", {
|
||||||
|
retryable: true,
|
||||||
|
recovery: "RESUME",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = createResumableUploadRuntime({
|
||||||
|
controlPlane: harness.control,
|
||||||
|
partExecutor: executorFor(harness),
|
||||||
|
checkpoints,
|
||||||
|
mutationLock: noContentionLock,
|
||||||
|
crypto,
|
||||||
|
policy: runtimePolicy({
|
||||||
|
providerAttemptTimeoutMs: 5,
|
||||||
|
cleanupDeadlineMs: 1_000,
|
||||||
|
maxRetries: 0,
|
||||||
|
}),
|
||||||
|
now: () => 1_000,
|
||||||
|
random: () => 0,
|
||||||
|
sleep: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploading = runtime.upload({
|
||||||
|
uploadKey: "upload_key_raw_2",
|
||||||
|
purpose: "attachment",
|
||||||
|
mediaType: "application/octet-stream",
|
||||||
|
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
|
||||||
|
signal: activeSignal,
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(releaseProvider).toBeDefined());
|
||||||
|
await uploading;
|
||||||
|
|
||||||
|
const disposing = runtime.dispose();
|
||||||
|
releaseProvider?.();
|
||||||
|
await expect(disposing).resolves.toMatchObject({ ok: true });
|
||||||
|
expect(runtime.lifecycle()).toBe("CLOSED");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,319 +32,19 @@ import type {
|
|||||||
} from "../../src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts";
|
} from "../../src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts";
|
||||||
import type { UploadMutationLock } from "../../src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts";
|
import type { UploadMutationLock } from "../../src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts";
|
||||||
|
|
||||||
type TestCapability = Readonly<{ id: string }>;
|
import {
|
||||||
|
activeSignal,
|
||||||
const activeSignal = new AbortController().signal;
|
byteStreamSource,
|
||||||
const noContentionLock: UploadMutationLock = Object.freeze({
|
createControlHarness,
|
||||||
async run<Value>(
|
createMemoryCancellationPair,
|
||||||
_uploadKey: string,
|
createSerialMutationLock,
|
||||||
_signal: AbortSignal,
|
executorFor,
|
||||||
task: () => Promise<Value>,
|
MemoryCheckpointStore,
|
||||||
): Promise<Value> {
|
noContentionLock,
|
||||||
return await task();
|
rangeSource,
|
||||||
},
|
runtimePolicy,
|
||||||
});
|
type TestCapability,
|
||||||
|
} from "./resumable-upload-runtime-fixture.ts";
|
||||||
function createSerialMutationLock(): UploadMutationLock {
|
|
||||||
let tail = Promise.resolve();
|
|
||||||
return Object.freeze({
|
|
||||||
run<Value>(
|
|
||||||
_uploadKey: string,
|
|
||||||
signal: AbortSignal,
|
|
||||||
task: () => Promise<Value>,
|
|
||||||
): Promise<Value> {
|
|
||||||
const result = tail.then(async () => {
|
|
||||||
if (signal.aborted) {
|
|
||||||
throw new DOMException(
|
|
||||||
"The operation was aborted.",
|
|
||||||
"AbortError",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return await task();
|
|
||||||
});
|
|
||||||
tail = result.then(
|
|
||||||
() => undefined,
|
|
||||||
() => undefined,
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMemoryCancellationPair(): readonly [
|
|
||||||
UploadCancellationChannel,
|
|
||||||
UploadCancellationChannel,
|
|
||||||
] {
|
|
||||||
const listeners = [
|
|
||||||
new Set<UploadCancellationListener>(),
|
|
||||||
new Set<UploadCancellationListener>(),
|
|
||||||
] as const;
|
|
||||||
const channels = listeners.map((ownListeners, ownIndex) => {
|
|
||||||
let closed = false;
|
|
||||||
return Object.freeze({
|
|
||||||
publish(uploadKey: string) {
|
|
||||||
if (closed) return false;
|
|
||||||
for (const [index, peerListeners] of listeners.entries()) {
|
|
||||||
if (index === ownIndex) continue;
|
|
||||||
for (const listener of [...peerListeners]) {
|
|
||||||
listener(uploadKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
subscribe(listener: UploadCancellationListener) {
|
|
||||||
if (closed) throw new TypeError("closed");
|
|
||||||
ownListeners.add(listener);
|
|
||||||
return () => ownListeners.delete(listener);
|
|
||||||
},
|
|
||||||
close() {
|
|
||||||
closed = true;
|
|
||||||
ownListeners.clear();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return channels as unknown as readonly [
|
|
||||||
UploadCancellationChannel,
|
|
||||||
UploadCancellationChannel,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
class MemoryCheckpointStore implements ResumableUploadCheckpointStore {
|
|
||||||
readonly rows = new Map<string, ResumableUploadCheckpoint>();
|
|
||||||
closed = false;
|
|
||||||
|
|
||||||
async read(
|
|
||||||
uploadKey: string,
|
|
||||||
): Promise<BrowserDataResult<ResumableUploadCheckpoint | null>> {
|
|
||||||
return browserDataSuccess(
|
|
||||||
structuredClone(this.rows.get(uploadKey) ?? null),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async compareAndSwap(
|
|
||||||
input: Parameters<
|
|
||||||
ResumableUploadCheckpointStore["compareAndSwap"]
|
|
||||||
>[0],
|
|
||||||
): Promise<BrowserDataResult<ResumableUploadCheckpoint>> {
|
|
||||||
const current = this.rows.get(input.checkpoint.uploadKey);
|
|
||||||
if (
|
|
||||||
(input.expectedRevision === null && current) ||
|
|
||||||
(input.expectedRevision !== null &&
|
|
||||||
current?.revision !== input.expectedRevision)
|
|
||||||
) {
|
|
||||||
return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", {
|
|
||||||
recovery: "RECONCILE",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const snapshot = structuredClone(input.checkpoint);
|
|
||||||
this.rows.set(snapshot.uploadKey, snapshot);
|
|
||||||
return browserDataSuccess(snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(
|
|
||||||
input: Parameters<ResumableUploadCheckpointStore["remove"]>[0],
|
|
||||||
): Promise<BrowserDataResult<void>> {
|
|
||||||
const current = this.rows.get(input.uploadKey);
|
|
||||||
if (current?.revision !== input.expectedRevision) {
|
|
||||||
return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", {
|
|
||||||
recovery: "RECONCILE",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.rows.delete(input.uploadKey);
|
|
||||||
return browserDataSuccess(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
close(): void {
|
|
||||||
this.closed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function rangeSource(bytes: Uint8Array): ResumableUploadSource {
|
|
||||||
return Object.freeze({
|
|
||||||
kind: "RANGE_READER" as const,
|
|
||||||
reader: Object.freeze({
|
|
||||||
byteLength: bytes.byteLength,
|
|
||||||
async readRange(input: Readonly<{
|
|
||||||
offset: number;
|
|
||||||
length: number;
|
|
||||||
signal: AbortSignal;
|
|
||||||
}>) {
|
|
||||||
if (input.signal.aborted) {
|
|
||||||
return browserDataFailure("ABORTED", "FILE_READ");
|
|
||||||
}
|
|
||||||
return browserDataSuccess(
|
|
||||||
bytes.slice(input.offset, input.offset + input.length),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function byteStreamSource(bytes: Uint8Array): ResumableUploadSource {
|
|
||||||
return Object.freeze({
|
|
||||||
kind: "FILE_BYTE_SOURCE" as const,
|
|
||||||
bytes: Object.freeze({
|
|
||||||
byteLength: bytes.byteLength,
|
|
||||||
async *stream(signal: AbortSignal) {
|
|
||||||
if (signal.aborted) {
|
|
||||||
yield browserDataFailure("ABORTED", "FILE_READ");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
yield browserDataSuccess(bytes.slice(0, 3));
|
|
||||||
yield browserDataSuccess(bytes.slice(3));
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function runtimePolicy(
|
|
||||||
overrides: Partial<
|
|
||||||
Parameters<typeof resolveResumableUploadRuntimePolicy>[0]
|
|
||||||
> = {},
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
partSizeBytes: 4,
|
|
||||||
maxFileBytes: 100,
|
|
||||||
maxPartCount: 25,
|
|
||||||
maxConcurrency: 3,
|
|
||||||
maxInFlightBytes: 48,
|
|
||||||
partBufferCopyFactor: 4,
|
|
||||||
maxSourceChunkBytes: 8,
|
|
||||||
maxRetries: 2,
|
|
||||||
retryBaseDelayMs: 1,
|
|
||||||
retryMaxDelayMs: 10,
|
|
||||||
maxRetryAfterMs: 100,
|
|
||||||
capabilityRefreshSkewMs: 5,
|
|
||||||
maxSessionLifetimeMs: 10_000,
|
|
||||||
providerAttemptTimeoutMs: 100,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type ControlHarness = Readonly<{
|
|
||||||
control: ResumableUploadControlPlane<TestCapability>;
|
|
||||||
accepted: Map<number, UploadPartReceipt>;
|
|
||||||
issued: ReturnType<typeof vi.fn>;
|
|
||||||
completedParts: UploadPartReceipt[][];
|
|
||||||
getSession(): UploadSession | null;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
function createControlHarness(options: Readonly<{
|
|
||||||
now?: number;
|
|
||||||
serverMaxConcurrency?: number;
|
|
||||||
sessionId?: (createIndex: number) => string;
|
|
||||||
statusParts?: (
|
|
||||||
session: UploadSession,
|
|
||||||
accepted: Map<number, UploadPartReceipt>,
|
|
||||||
) => readonly UploadPartReceipt[];
|
|
||||||
issueCapability?: (
|
|
||||||
input: Parameters<
|
|
||||||
ResumableUploadControlPlane<TestCapability>["issuePartCapability"]
|
|
||||||
>[0],
|
|
||||||
callIndex: number,
|
|
||||||
) => UploadProviderResult<Readonly<{
|
|
||||||
capability: TestCapability;
|
|
||||||
uploadBindingSha256: string;
|
|
||||||
expiresAtEpochMs: number;
|
|
||||||
}>>;
|
|
||||||
}> = {}): ControlHarness {
|
|
||||||
const now = options.now ?? 1_000;
|
|
||||||
const accepted = new Map<number, UploadPartReceipt>();
|
|
||||||
const completedParts: UploadPartReceipt[][] = [];
|
|
||||||
let session: UploadSession | null = null;
|
|
||||||
let createCount = 0;
|
|
||||||
let issueCount = 0;
|
|
||||||
const issued = vi.fn();
|
|
||||||
const control: ResumableUploadControlPlane<TestCapability> = {
|
|
||||||
async createSession(input) {
|
|
||||||
createCount += 1;
|
|
||||||
session = Object.freeze({
|
|
||||||
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
|
||||||
sessionId:
|
|
||||||
options.sessionId?.(createCount) ?? "session_01",
|
|
||||||
requestBindingSha256: input.requestBindingSha256,
|
|
||||||
fingerprint: input.fingerprint,
|
|
||||||
partSizeBytes: input.requestedPartSizeBytes,
|
|
||||||
partCount: input.fingerprint.partCount,
|
|
||||||
maxConcurrency: options.serverMaxConcurrency ?? 2,
|
|
||||||
expiresAtEpochMs: now + 5_000,
|
|
||||||
});
|
|
||||||
return browserDataSuccess(session);
|
|
||||||
},
|
|
||||||
async getStatus() {
|
|
||||||
if (!session) {
|
|
||||||
return browserDataFailure("NOT_FOUND", "UPLOAD_RECONCILE");
|
|
||||||
}
|
|
||||||
const parts =
|
|
||||||
options.statusParts?.(session, accepted) ??
|
|
||||||
[...accepted.values()].sort(
|
|
||||||
(left, right) => left.partNumber - right.partNumber,
|
|
||||||
);
|
|
||||||
return browserDataSuccess({
|
|
||||||
state: "ACTIVE",
|
|
||||||
session,
|
|
||||||
acceptedParts: parts,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
async issuePartCapability(input) {
|
|
||||||
issueCount += 1;
|
|
||||||
issued(input);
|
|
||||||
return (
|
|
||||||
options.issueCapability?.(input, issueCount) ??
|
|
||||||
browserDataSuccess({
|
|
||||||
capability: Object.freeze({ id: `cap-${issueCount}` }),
|
|
||||||
uploadBindingSha256: input.uploadBindingSha256,
|
|
||||||
expiresAtEpochMs: now + 4_000,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
},
|
|
||||||
async complete(input) {
|
|
||||||
completedParts.push([...input.orderedParts]);
|
|
||||||
return browserDataSuccess({
|
|
||||||
state: "QUARANTINED",
|
|
||||||
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
|
||||||
sessionId: input.sessionId,
|
|
||||||
requestBindingSha256: input.requestBindingSha256,
|
|
||||||
fingerprint: input.fingerprint,
|
|
||||||
resourceId: "resource_01",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
async abort() {
|
|
||||||
return browserDataSuccess({ state: "ABORTED" });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
control,
|
|
||||||
accepted,
|
|
||||||
issued,
|
|
||||||
completedParts,
|
|
||||||
getSession: () => session,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function executorFor(
|
|
||||||
harness: ControlHarness,
|
|
||||||
options: Readonly<{
|
|
||||||
delay?: () => Promise<void>;
|
|
||||||
onActive?: (active: number) => void;
|
|
||||||
}> = {},
|
|
||||||
): UploadPartExecutor<TestCapability> {
|
|
||||||
let active = 0;
|
|
||||||
return {
|
|
||||||
async uploadPart(input) {
|
|
||||||
active += 1;
|
|
||||||
options.onActive?.(active);
|
|
||||||
await options.delay?.();
|
|
||||||
active -= 1;
|
|
||||||
const receipt = Object.freeze({
|
|
||||||
...input.part,
|
|
||||||
receiptToken: `etag-part-${input.part.partNumber}`,
|
|
||||||
});
|
|
||||||
harness.accepted.set(input.part.partNumber, receipt);
|
|
||||||
return browserDataSuccess(receipt);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("production resumable upload runtime", () => {
|
describe("production resumable upload runtime", () => {
|
||||||
it("disposes through one drain that proves quiescence", async () => {
|
it("disposes through one drain that proves quiescence", async () => {
|
||||||
@@ -1280,177 +980,3 @@ describe("production resumable upload runtime", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
/**
|
|
||||||
* TR-RR-06. A non-cooperative mutation lock or provider must not make teardown
|
|
||||||
* unbounded: `dispose()` bounds its drain and reports honestly when the runtime
|
|
||||||
* is still CLOSING, and an abort is admitted physical work it cannot step over.
|
|
||||||
*/
|
|
||||||
describe("TR-RR-06 bounded resumable teardown", () => {
|
|
||||||
it("reports an unproved drain instead of waiting forever", async () => {
|
|
||||||
const checkpoints = new MemoryCheckpointStore();
|
|
||||||
const harness = createControlHarness();
|
|
||||||
const runtime = createResumableUploadRuntime({
|
|
||||||
controlPlane: harness.control,
|
|
||||||
partExecutor: executorFor(harness, { delay: async () => {} }),
|
|
||||||
checkpoints,
|
|
||||||
// A lock that never grants: dispose must still be bounded.
|
|
||||||
mutationLock: Object.freeze({
|
|
||||||
async run<Value>(): Promise<Value> {
|
|
||||||
return await new Promise<never>(() => {});
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
crypto,
|
|
||||||
policy: runtimePolicy({ cleanupDeadlineMs: 20 }),
|
|
||||||
now: () => 1_000,
|
|
||||||
random: () => 0,
|
|
||||||
sleep: async () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
void runtime.upload({
|
|
||||||
uploadKey: "upload_key_hung",
|
|
||||||
purpose: "attachment",
|
|
||||||
mediaType: "application/octet-stream",
|
|
||||||
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
|
|
||||||
signal: activeSignal,
|
|
||||||
});
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
const disposed = await runtime.dispose();
|
|
||||||
expect(disposed.ok).toBe(false);
|
|
||||||
// Still CLOSING: physical work the caller must not treat as finished.
|
|
||||||
expect(runtime.lifecycle()).toBe("CLOSING");
|
|
||||||
expect(checkpoints.closed).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closes once every admitted operation settles", async () => {
|
|
||||||
const checkpoints = new MemoryCheckpointStore();
|
|
||||||
const harness = createControlHarness();
|
|
||||||
const runtime = createResumableUploadRuntime({
|
|
||||||
controlPlane: harness.control,
|
|
||||||
partExecutor: executorFor(harness, { delay: async () => {} }),
|
|
||||||
checkpoints,
|
|
||||||
mutationLock: noContentionLock,
|
|
||||||
crypto,
|
|
||||||
policy: runtimePolicy({ cleanupDeadlineMs: 200 }),
|
|
||||||
now: () => 1_000,
|
|
||||||
random: () => 0,
|
|
||||||
sleep: async () => {},
|
|
||||||
});
|
|
||||||
const disposed = await runtime.dispose();
|
|
||||||
expect(disposed).toMatchObject({ ok: true });
|
|
||||||
expect(runtime.lifecycle()).toBe("CLOSED");
|
|
||||||
expect(checkpoints.closed).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TR-04. `dispose()` proved quiescence from the wrapper registry only. A
|
|
||||||
* provider that ignored its attempt deadline let the wrapper settle first and
|
|
||||||
* leave the set empty, so teardown reported a drained runtime — and closed the
|
|
||||||
* checkpoint store — while the provider was still running.
|
|
||||||
*/
|
|
||||||
describe("TR-04 teardown waits for raw provider work, not only its wrapper", () => {
|
|
||||||
it("refuses to report a drained runtime while a provider is still running", async () => {
|
|
||||||
const harness = createControlHarness();
|
|
||||||
const checkpoints = new MemoryCheckpointStore();
|
|
||||||
const closeStore = vi.spyOn(checkpoints, "close");
|
|
||||||
let releaseProvider: (() => void) | undefined;
|
|
||||||
// Ignores the attempt signal entirely and outlives its own deadline.
|
|
||||||
harness.control.createSession = () =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
releaseProvider = () =>
|
|
||||||
resolve(
|
|
||||||
browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", {
|
|
||||||
retryable: true,
|
|
||||||
recovery: "RESUME",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const runtime = createResumableUploadRuntime({
|
|
||||||
controlPlane: harness.control,
|
|
||||||
partExecutor: executorFor(harness),
|
|
||||||
checkpoints,
|
|
||||||
mutationLock: noContentionLock,
|
|
||||||
crypto,
|
|
||||||
policy: runtimePolicy({
|
|
||||||
providerAttemptTimeoutMs: 5,
|
|
||||||
cleanupDeadlineMs: 25,
|
|
||||||
maxRetries: 0,
|
|
||||||
}),
|
|
||||||
now: () => 1_000,
|
|
||||||
random: () => 0,
|
|
||||||
sleep: async () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploading = runtime.upload({
|
|
||||||
uploadKey: "upload_key_raw",
|
|
||||||
purpose: "attachment",
|
|
||||||
mediaType: "application/octet-stream",
|
|
||||||
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
|
|
||||||
signal: activeSignal,
|
|
||||||
});
|
|
||||||
await vi.waitFor(() => expect(releaseProvider).toBeDefined());
|
|
||||||
// The wrapper has already given up on the attempt.
|
|
||||||
await uploading;
|
|
||||||
|
|
||||||
const disposed = await runtime.dispose();
|
|
||||||
expect(disposed.ok).toBe(false);
|
|
||||||
if (!disposed.ok) {
|
|
||||||
expect(disposed.error.code).toBe("UNAVAILABLE");
|
|
||||||
expect(disposed.error.recovery).toBe("RESUME");
|
|
||||||
}
|
|
||||||
// The store stays open while something could still write a checkpoint.
|
|
||||||
expect(closeStore).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
releaseProvider?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports a drained runtime once the raw provider settles", async () => {
|
|
||||||
const harness = createControlHarness();
|
|
||||||
const checkpoints = new MemoryCheckpointStore();
|
|
||||||
let releaseProvider: (() => void) | undefined;
|
|
||||||
harness.control.createSession = () =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
releaseProvider = () =>
|
|
||||||
resolve(
|
|
||||||
browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", {
|
|
||||||
retryable: true,
|
|
||||||
recovery: "RESUME",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const runtime = createResumableUploadRuntime({
|
|
||||||
controlPlane: harness.control,
|
|
||||||
partExecutor: executorFor(harness),
|
|
||||||
checkpoints,
|
|
||||||
mutationLock: noContentionLock,
|
|
||||||
crypto,
|
|
||||||
policy: runtimePolicy({
|
|
||||||
providerAttemptTimeoutMs: 5,
|
|
||||||
cleanupDeadlineMs: 1_000,
|
|
||||||
maxRetries: 0,
|
|
||||||
}),
|
|
||||||
now: () => 1_000,
|
|
||||||
random: () => 0,
|
|
||||||
sleep: async () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploading = runtime.upload({
|
|
||||||
uploadKey: "upload_key_raw_2",
|
|
||||||
purpose: "attachment",
|
|
||||||
mediaType: "application/octet-stream",
|
|
||||||
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
|
|
||||||
signal: activeSignal,
|
|
||||||
});
|
|
||||||
await vi.waitFor(() => expect(releaseProvider).toBeDefined());
|
|
||||||
await uploading;
|
|
||||||
|
|
||||||
const disposing = runtime.dispose();
|
|
||||||
releaseProvider?.();
|
|
||||||
await expect(disposing).resolves.toMatchObject({ ok: true });
|
|
||||||
expect(runtime.lifecycle()).toBe("CLOSED");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,541 @@
|
|||||||
|
import {
|
||||||
|
createHash,
|
||||||
|
generateKeyPairSync,
|
||||||
|
sign,
|
||||||
|
type KeyObject,
|
||||||
|
} from "node:crypto";
|
||||||
|
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { localEvidenceAssessmentArtifactSchema } from "../../scripts/contracts/release-artifacts.ts";
|
||||||
|
import { PROMOTED_FILE_NAMES } from "../../scripts/contracts/promotion-artifacts.ts";
|
||||||
|
import {
|
||||||
|
providerEvidenceSignaturePayload,
|
||||||
|
trustPolicySha256,
|
||||||
|
} from "../../scripts/lib/provider-evidence.ts";
|
||||||
|
import {
|
||||||
|
LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||||
|
distSha256,
|
||||||
|
type ReleaseCandidateManifest,
|
||||||
|
} from "../../scripts/lib/release-candidate.ts";
|
||||||
|
import { supplyChainDigest } from "../../scripts/lib/supply-chain.ts";
|
||||||
|
|
||||||
|
export const PROCESS_HEAVY_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
export const digest = (value: string): string =>
|
||||||
|
createHash("sha256").update(value).digest("hex");
|
||||||
|
export const digestBytes = (value: Buffer): string =>
|
||||||
|
createHash("sha256").update(value).digest("hex");
|
||||||
|
|
||||||
|
export function passingAssessment(): any {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
artifactType: "local-evidence-assessment" as const,
|
||||||
|
generatedAt: "2026-08-02T00:00:00.000Z",
|
||||||
|
status: "PASS" as const,
|
||||||
|
verifier: {
|
||||||
|
id: "clean-architecture-frontend-template/local-evidence-verifier",
|
||||||
|
version: "1",
|
||||||
|
sourceSha256: digest("verifier source"),
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
revision: "a".repeat(40),
|
||||||
|
sourceSetSha256: digest("source set"),
|
||||||
|
},
|
||||||
|
candidate: {
|
||||||
|
distSha256: digest("dist"),
|
||||||
|
lockfileSha256: digest("lockfile"),
|
||||||
|
sbomSha256: digest("sbom"),
|
||||||
|
},
|
||||||
|
secretScan: {
|
||||||
|
policySha256: digest("secret policy"),
|
||||||
|
sarifSha256: digest("secret sarif"),
|
||||||
|
scanInputSha256: digest("secret scan input"),
|
||||||
|
},
|
||||||
|
policyInputs: [
|
||||||
|
{
|
||||||
|
path: "config/security/dependency-policy.json",
|
||||||
|
bytes: 3,
|
||||||
|
sha256: digest("{}\n"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
evidenceInputs: [
|
||||||
|
{ path: "pnpm-lock.yaml", bytes: 9, sha256: digest("lockfile\n") },
|
||||||
|
],
|
||||||
|
checks: {
|
||||||
|
release: "PASS" as const,
|
||||||
|
supplyChain: "PASS" as const,
|
||||||
|
dependencyPolicy: "PASS" as const,
|
||||||
|
licensePolicy: "PASS" as const,
|
||||||
|
vulnerabilityPolicy: "PASS" as const,
|
||||||
|
secretScan: "PASS" as const,
|
||||||
|
},
|
||||||
|
failures: [] as string[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function providerExpectedContext() {
|
||||||
|
return {
|
||||||
|
run: { id: "run-42", attempt: 1 },
|
||||||
|
source: { revision: "b".repeat(40), sourceSetSha256: digest("provider source") },
|
||||||
|
candidate: {
|
||||||
|
archiveSha256: digest("archive"),
|
||||||
|
bundleSha256: digest("bundle"),
|
||||||
|
distSha256: digest("provider dist"),
|
||||||
|
lockfileSha256: digest("provider lockfile"),
|
||||||
|
},
|
||||||
|
secretScanAttestation: {
|
||||||
|
status: "PASS" as const,
|
||||||
|
localEvidenceAssessmentSha256: digest("provider assessment"),
|
||||||
|
sourceSetSha256: digest("provider source"),
|
||||||
|
policySha256: digest("provider secret policy"),
|
||||||
|
sarifSha256: digest("provider secret sarif"),
|
||||||
|
scanInputSha256: digest("provider secret input"),
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function privatePromotionFiles() {
|
||||||
|
return PROMOTED_FILE_NAMES.map((name) => {
|
||||||
|
const bytes = Buffer.from(`${name}\n`);
|
||||||
|
return { name, bytes, sha256: digestBytes(bytes) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syntheticSignedPromotionBundle() {
|
||||||
|
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||||
|
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||||
|
const archiveBytes = Buffer.from("synthetic signed replay archive\n");
|
||||||
|
const run = { id: "signed-run", attempt: 1 } as const;
|
||||||
|
const source = {
|
||||||
|
revision: "a".repeat(40),
|
||||||
|
sourceSetSha256: digest("synthetic-source-set"),
|
||||||
|
} as const;
|
||||||
|
const candidate = {
|
||||||
|
archiveSha256: digestBytes(archiveBytes),
|
||||||
|
bundleSha256: digest("synthetic-bundle"),
|
||||||
|
distSha256: digest("synthetic-dist"),
|
||||||
|
lockfileSha256: digest("synthetic-lock"),
|
||||||
|
} as const;
|
||||||
|
const vulnerability = signedProviderV2(
|
||||||
|
{
|
||||||
|
schemaVersion: 2,
|
||||||
|
evidenceType: "vulnerability-report",
|
||||||
|
provider: "synthetic-vulnerability-provider",
|
||||||
|
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||||
|
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||||
|
run: { ...run, invocationNonce: "1".repeat(64) },
|
||||||
|
source,
|
||||||
|
candidate,
|
||||||
|
secretScanAttestation: {
|
||||||
|
status: "PASS",
|
||||||
|
localEvidenceAssessmentSha256: digest("synthetic-assessment"),
|
||||||
|
sourceSetSha256: source.sourceSetSha256,
|
||||||
|
policySha256: digest("synthetic-policy"),
|
||||||
|
sarifSha256: digest("synthetic-sarif"),
|
||||||
|
scanInputSha256: digest("synthetic-scan-input"),
|
||||||
|
},
|
||||||
|
findings: [],
|
||||||
|
},
|
||||||
|
"synthetic-vulnerability",
|
||||||
|
vulnerabilityKeys.publicKey,
|
||||||
|
vulnerabilityKeys.privateKey,
|
||||||
|
);
|
||||||
|
const provenance = signedProviderV2(
|
||||||
|
{
|
||||||
|
schemaVersion: 2,
|
||||||
|
evidenceType: "provenance-attestation",
|
||||||
|
provider: "synthetic-provenance-provider",
|
||||||
|
signer: "synthetic-signer",
|
||||||
|
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||||
|
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||||
|
run: { ...run, invocationNonce: "2".repeat(64) },
|
||||||
|
source,
|
||||||
|
candidate,
|
||||||
|
subject: { name: "dist", digest: { sha256: candidate.distSha256 } },
|
||||||
|
},
|
||||||
|
"synthetic-provenance",
|
||||||
|
provenanceKeys.publicKey,
|
||||||
|
provenanceKeys.privateKey,
|
||||||
|
);
|
||||||
|
const vulnerabilityBytes = Buffer.from(`${JSON.stringify(vulnerability)}\n`);
|
||||||
|
const provenanceBytes = Buffer.from(`${JSON.stringify(provenance)}\n`);
|
||||||
|
const vulnerabilityTrust = trust("synthetic-vulnerability", vulnerabilityKeys.publicKey);
|
||||||
|
const provenanceTrust = trust("synthetic-provenance", provenanceKeys.publicKey);
|
||||||
|
const providerEvidence = {
|
||||||
|
vulnerabilityReportSha256: digestBytes(vulnerabilityBytes),
|
||||||
|
provenanceAttestationSha256: digestBytes(provenanceBytes),
|
||||||
|
vulnerabilityInvocationNonce: "1".repeat(64),
|
||||||
|
provenanceInvocationNonce: "2".repeat(64),
|
||||||
|
vulnerabilityKeyId: vulnerabilityTrust.keyId,
|
||||||
|
vulnerabilityKeyFingerprint: vulnerabilityTrust.publicKeyFingerprint,
|
||||||
|
provenanceKeyId: provenanceTrust.keyId,
|
||||||
|
provenanceKeyFingerprint: provenanceTrust.publicKeyFingerprint,
|
||||||
|
secretScanAttestation: vulnerability.secretScanAttestation,
|
||||||
|
};
|
||||||
|
const common = {
|
||||||
|
schemaVersion: 3,
|
||||||
|
verifiedAt: "2026-08-02T01:00:00.000Z",
|
||||||
|
status: "PASS",
|
||||||
|
verifier: {
|
||||||
|
id: "clean-architecture-frontend-template/promotion-verifier",
|
||||||
|
version: "3",
|
||||||
|
},
|
||||||
|
run,
|
||||||
|
source,
|
||||||
|
candidate,
|
||||||
|
providerEvidence,
|
||||||
|
trustPolicySha256: trustPolicySha256({ vulnerabilityTrust, provenanceTrust }),
|
||||||
|
failures: [],
|
||||||
|
};
|
||||||
|
const providerBytes = Buffer.from(
|
||||||
|
`${JSON.stringify({
|
||||||
|
...common,
|
||||||
|
artifactType: "provider-verification",
|
||||||
|
vulnerabilityStatus: "PASS",
|
||||||
|
provenanceAttestationStatus: "PASS",
|
||||||
|
}, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
const promotionBytes = Buffer.from(
|
||||||
|
`${JSON.stringify({
|
||||||
|
...common,
|
||||||
|
artifactType: "promotion-verification",
|
||||||
|
localEvidenceStatus: "PASS",
|
||||||
|
localEvidenceAssessmentSha256: digest("synthetic-assessment"),
|
||||||
|
providerVerificationSha256: digestBytes(providerBytes),
|
||||||
|
}, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
files: {
|
||||||
|
"release-candidate.tar.gz": archiveBytes,
|
||||||
|
"vulnerability-report.json": vulnerabilityBytes,
|
||||||
|
"provenance-attestation.json": provenanceBytes,
|
||||||
|
"provider-verification.json": providerBytes,
|
||||||
|
"promotion-verification.json": promotionBytes,
|
||||||
|
},
|
||||||
|
verification: {
|
||||||
|
vulnerabilityTrust,
|
||||||
|
provenanceTrust,
|
||||||
|
nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"),
|
||||||
|
expected: {
|
||||||
|
run,
|
||||||
|
sourceRevision: source.revision,
|
||||||
|
sourceSetSha256: source.sourceSetSha256,
|
||||||
|
archiveSha256: candidate.archiveSha256,
|
||||||
|
bundleSha256: candidate.bundleSha256,
|
||||||
|
distSha256: candidate.distSha256,
|
||||||
|
lockfileSha256: candidate.lockfileSha256,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vulnerabilityPem: vulnerabilityKeys.publicKey.export({ type: "spki", format: "pem" }),
|
||||||
|
provenancePem: provenanceKeys.publicKey.export({ type: "spki", format: "pem" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fingerprint(publicKey: KeyObject): string {
|
||||||
|
return `sha256:${createHash("sha256")
|
||||||
|
.update(publicKey.export({ type: "spki", format: "der" }))
|
||||||
|
.digest("hex")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trust(keyId: string, publicKey: KeyObject) {
|
||||||
|
return { keyId, publicKey, publicKeyFingerprint: fingerprint(publicKey) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signedProviderV2(
|
||||||
|
unsigned: Record<string, unknown>,
|
||||||
|
keyId: string,
|
||||||
|
publicKey: KeyObject,
|
||||||
|
privateKey: KeyObject,
|
||||||
|
fingerprintOverride?: string,
|
||||||
|
): Record<string, any> {
|
||||||
|
const { signature: existingSignature, ...payload } = unsigned;
|
||||||
|
const value = {
|
||||||
|
...payload,
|
||||||
|
signature: {
|
||||||
|
algorithm: "Ed25519" as const,
|
||||||
|
keyId,
|
||||||
|
publicKeyFingerprint:
|
||||||
|
fingerprintOverride ??
|
||||||
|
(existingSignature && typeof existingSignature === "object" &&
|
||||||
|
"publicKeyFingerprint" in existingSignature
|
||||||
|
? String(existingSignature.publicKeyFingerprint)
|
||||||
|
: fingerprint(publicKey)),
|
||||||
|
value: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
value.signature.value = sign(
|
||||||
|
null,
|
||||||
|
providerEvidenceSignaturePayload(value),
|
||||||
|
privateKey,
|
||||||
|
).toString("base64");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function providerUnsigned(
|
||||||
|
kind: "vulnerability" | "provenance",
|
||||||
|
expected: ReturnType<typeof providerExpectedContext>,
|
||||||
|
): Record<string, any> {
|
||||||
|
const common = {
|
||||||
|
source: expected.source,
|
||||||
|
candidate: expected.candidate,
|
||||||
|
schemaVersion: 2,
|
||||||
|
evidenceType:
|
||||||
|
kind === "vulnerability"
|
||||||
|
? "vulnerability-report"
|
||||||
|
: "provenance-attestation",
|
||||||
|
provider: `fixture-${kind}`,
|
||||||
|
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||||
|
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||||
|
run: {
|
||||||
|
...expected.run,
|
||||||
|
invocationNonce: kind === "vulnerability" ? "1".repeat(64) : "2".repeat(64),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return kind === "vulnerability"
|
||||||
|
? {
|
||||||
|
...common,
|
||||||
|
secretScanAttestation: expected.secretScanAttestation,
|
||||||
|
findings: [],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...common,
|
||||||
|
signer: "fixture-workload",
|
||||||
|
subject: {
|
||||||
|
name: "dist",
|
||||||
|
digest: { sha256: expected.candidate.distSha256 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createArchivedAssessmentFixture(): Promise<{
|
||||||
|
root: string;
|
||||||
|
manifest: ReleaseCandidateManifest;
|
||||||
|
assessmentSha256: string;
|
||||||
|
}> {
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), "archived-assessment-"));
|
||||||
|
const sourceRevision = "a".repeat(40);
|
||||||
|
const sourceSetSha256 = digest("source set");
|
||||||
|
const releaseManifestBytes = Buffer.from(
|
||||||
|
`${JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
appVersion: "1.0.0",
|
||||||
|
buildId: "build-1",
|
||||||
|
commitSha: sourceRevision,
|
||||||
|
configSchemaVersion: "1",
|
||||||
|
apiContractVersion: "1",
|
||||||
|
assetManifestHash: digest("vite manifest"),
|
||||||
|
releaseId: "release-1",
|
||||||
|
builtAt: "2026-08-02T00:00:00.000Z",
|
||||||
|
routeChunks: { home: "assets/home.js" },
|
||||||
|
})}\n`,
|
||||||
|
);
|
||||||
|
const distInputs = [
|
||||||
|
{ path: "dist/app.js", bytes: Buffer.byteLength("app\n"), sha256: digest("app\n"), gzipBytes: 0 },
|
||||||
|
{
|
||||||
|
path: "dist/release-manifest.json",
|
||||||
|
bytes: releaseManifestBytes.byteLength,
|
||||||
|
sha256: digestBytes(releaseManifestBytes),
|
||||||
|
gzipBytes: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const candidateDist = distSha256(distInputs);
|
||||||
|
const sbomBytes = Buffer.from(
|
||||||
|
`${JSON.stringify({
|
||||||
|
bomFormat: "CycloneDX",
|
||||||
|
specVersion: "1.6",
|
||||||
|
serialNumber: "urn:uuid:00000000-0000-4000-8000-000000000001",
|
||||||
|
version: 1,
|
||||||
|
metadata: {
|
||||||
|
component: { type: "application", name: "fixture", version: "1.0.0" },
|
||||||
|
properties: [],
|
||||||
|
},
|
||||||
|
components: [],
|
||||||
|
dependencies: [],
|
||||||
|
})}\n`,
|
||||||
|
);
|
||||||
|
const sbomSha256 = digestBytes(sbomBytes);
|
||||||
|
const lockfileBytes = Buffer.from("lockfile\n");
|
||||||
|
const lockfileDigest = digestBytes(lockfileBytes);
|
||||||
|
const buildManifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
buildId: "build-1",
|
||||||
|
commitSha: sourceRevision,
|
||||||
|
releaseId: "release-1",
|
||||||
|
moduleInventoryHash: digest("module inventory"),
|
||||||
|
generatedAt: "2026-08-02T00:00:00.000Z",
|
||||||
|
buildContext: {
|
||||||
|
nodeVersion: "v24.0.0",
|
||||||
|
packageManagerVersion: "11.0.0",
|
||||||
|
runnerImage: "linux-x64",
|
||||||
|
sourceDateEpoch: "1785638400",
|
||||||
|
},
|
||||||
|
outputs: {
|
||||||
|
directory: "dist",
|
||||||
|
viteManifest: "dist/.vite/manifest.json",
|
||||||
|
moduleInventory: "artifacts/quality/vite-module-inventory.json",
|
||||||
|
routeChunks: { home: "assets/home.js" },
|
||||||
|
runtimeConfigSchema: "dist/runtime-config.schema.json",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const provenance = {
|
||||||
|
_type: "https://in-toto.io/Statement/v1",
|
||||||
|
subject: [{ name: "dist", digest: { sha256: candidateDist } }],
|
||||||
|
predicateType: "https://slsa.dev/provenance/v1",
|
||||||
|
predicate: {
|
||||||
|
buildDefinition: {
|
||||||
|
buildType: "https://vite.dev/build/v1",
|
||||||
|
externalParameters: {},
|
||||||
|
internalParameters: {},
|
||||||
|
resolvedDependencies: [
|
||||||
|
{ uri: "pnpm-lock.yaml", digest: { sha256: lockfileDigest } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
runDetails: {
|
||||||
|
builder: { id: "fixture-builder" },
|
||||||
|
metadata: { invocationId: "LOCAL_UNSIGNED" },
|
||||||
|
},
|
||||||
|
materials: { lockfileSha256: lockfileDigest, sourceSetSha256, sbomSha256 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const supplyVerification = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
localStatus: "PASS",
|
||||||
|
promotionStatus: "FAIL_UNVERIFIED",
|
||||||
|
lockfileSha256: lockfileDigest,
|
||||||
|
sourceSetSha256,
|
||||||
|
distSha256: candidateDist,
|
||||||
|
sbomSha256,
|
||||||
|
dependencyDiff: { added: [], removed: [], changed: [], upgrades: [] },
|
||||||
|
highRiskReview: [],
|
||||||
|
vulnerabilityStatus: "FAIL_UNVERIFIED",
|
||||||
|
provenanceAttestationStatus: "FAIL_UNVERIFIED",
|
||||||
|
failures: [],
|
||||||
|
};
|
||||||
|
const members = new Map<string, Buffer>([
|
||||||
|
["dist/app.js", Buffer.from("app\n")],
|
||||||
|
["dist/release-manifest.json", releaseManifestBytes],
|
||||||
|
["pnpm-lock.yaml", lockfileBytes],
|
||||||
|
["artifacts/release/build-manifest.json", Buffer.from(`${JSON.stringify(buildManifest)}\n`)],
|
||||||
|
["artifacts/release/provenance.json", Buffer.from(`${JSON.stringify(provenance)}\n`)],
|
||||||
|
[
|
||||||
|
"artifacts/security/supply-chain-verification.json",
|
||||||
|
Buffer.from(`${JSON.stringify(supplyVerification)}\n`),
|
||||||
|
],
|
||||||
|
["artifacts/release/sbom.cdx.json", sbomBytes],
|
||||||
|
]);
|
||||||
|
const evidenceInputs = [...members.entries()]
|
||||||
|
.map(([memberPath, bytes]) => ({
|
||||||
|
path: memberPath,
|
||||||
|
bytes: bytes.byteLength,
|
||||||
|
sha256: digestBytes(bytes),
|
||||||
|
}))
|
||||||
|
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
|
||||||
|
const policyPaths = [
|
||||||
|
"config/security/dependency-baseline.approval.json",
|
||||||
|
"config/security/dependency-baseline.json",
|
||||||
|
"config/security/dependency-change-evidence.json",
|
||||||
|
"config/security/dependency-policy.json",
|
||||||
|
"config/security/secret-scan-policy.json",
|
||||||
|
"config/security/vulnerability-exceptions.json",
|
||||||
|
"config/security/vulnerability-policy.json",
|
||||||
|
"schemas/artifacts/build-manifest.schema.json",
|
||||||
|
"schemas/artifacts/dependency-inventory.schema.json",
|
||||||
|
"schemas/artifacts/supply-chain-verification.schema.json",
|
||||||
|
"scripts/contracts/release-artifacts.ts",
|
||||||
|
"scripts/create-release-candidate.ts",
|
||||||
|
"scripts/generate-supply-chain.ts",
|
||||||
|
"scripts/lib/build-manifest-outputs.ts",
|
||||||
|
"scripts/lib/json-schema.ts",
|
||||||
|
"scripts/lib/local-policy-evidence.ts",
|
||||||
|
"scripts/lib/local-release-evidence.ts",
|
||||||
|
"scripts/lib/release-candidate.ts",
|
||||||
|
"scripts/lib/release-input-evidence.ts",
|
||||||
|
"scripts/lib/release-runtime-coherence.ts",
|
||||||
|
"scripts/lib/repository-file-inventory.ts",
|
||||||
|
"scripts/lib/secret-scan-evaluator.ts",
|
||||||
|
"scripts/lib/secret-scan-policy.ts",
|
||||||
|
"scripts/lib/secret-scan.ts",
|
||||||
|
"scripts/lib/supply-chain.ts",
|
||||||
|
"scripts/lib/validated-json-artifact.ts",
|
||||||
|
"src/contracts/release-artifacts.ts",
|
||||||
|
"src/features/installed-contract-contributions.ts",
|
||||||
|
"src/features/installed-feature-contracts.ts",
|
||||||
|
];
|
||||||
|
const sbomRow = evidenceInputs.find(
|
||||||
|
({ path: memberPath }) => memberPath === "artifacts/release/sbom.cdx.json",
|
||||||
|
)!;
|
||||||
|
const policyInputs = policyPaths.map((policyPath) => ({
|
||||||
|
path: policyPath,
|
||||||
|
bytes: 2,
|
||||||
|
sha256: digest(`policy:${policyPath}`),
|
||||||
|
}));
|
||||||
|
const verifierPaths = new Set([
|
||||||
|
"scripts/contracts/release-artifacts.ts",
|
||||||
|
"scripts/create-release-candidate.ts",
|
||||||
|
"scripts/generate-supply-chain.ts",
|
||||||
|
"scripts/lib/build-manifest-outputs.ts",
|
||||||
|
"scripts/lib/json-schema.ts",
|
||||||
|
"scripts/lib/local-policy-evidence.ts",
|
||||||
|
"scripts/lib/local-release-evidence.ts",
|
||||||
|
"scripts/lib/release-candidate.ts",
|
||||||
|
"scripts/lib/release-input-evidence.ts",
|
||||||
|
"scripts/lib/release-runtime-coherence.ts",
|
||||||
|
"scripts/lib/repository-file-inventory.ts",
|
||||||
|
"scripts/lib/secret-scan-evaluator.ts",
|
||||||
|
"scripts/lib/secret-scan-policy.ts",
|
||||||
|
"scripts/lib/secret-scan.ts",
|
||||||
|
"scripts/lib/supply-chain.ts",
|
||||||
|
"scripts/lib/validated-json-artifact.ts",
|
||||||
|
"src/contracts/release-artifacts.ts",
|
||||||
|
"src/features/installed-contract-contributions.ts",
|
||||||
|
"src/features/installed-feature-contracts.ts",
|
||||||
|
]);
|
||||||
|
const assessment = localEvidenceAssessmentArtifactSchema.parse({
|
||||||
|
...passingAssessment(),
|
||||||
|
verifier: {
|
||||||
|
id: "clean-architecture-frontend-template/local-evidence-verifier",
|
||||||
|
version: "1",
|
||||||
|
sourceSha256: supplyChainDigest(
|
||||||
|
policyInputs.filter(({ path: policyPath }) => verifierPaths.has(policyPath)),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
source: { revision: sourceRevision, sourceSetSha256 },
|
||||||
|
candidate: {
|
||||||
|
distSha256: candidateDist,
|
||||||
|
lockfileSha256: evidenceInputs.find(({ path: memberPath }) => memberPath === "pnpm-lock.yaml")!
|
||||||
|
.sha256,
|
||||||
|
sbomSha256: sbomRow.sha256,
|
||||||
|
},
|
||||||
|
policyInputs,
|
||||||
|
evidenceInputs,
|
||||||
|
});
|
||||||
|
const assessmentBytes = Buffer.from(`${JSON.stringify(assessment)}\n`);
|
||||||
|
members.set(LOCAL_EVIDENCE_ASSESSMENT_PATH, assessmentBytes);
|
||||||
|
for (const [memberPath, bytes] of members) {
|
||||||
|
await mkdir(path.dirname(path.join(root, memberPath)), { recursive: true });
|
||||||
|
await writeFile(path.join(root, memberPath), bytes);
|
||||||
|
}
|
||||||
|
const files = [...members.entries()]
|
||||||
|
.map(([memberPath, bytes]) => ({
|
||||||
|
path: memberPath,
|
||||||
|
bytes: bytes.byteLength,
|
||||||
|
sha256: digestBytes(bytes),
|
||||||
|
}))
|
||||||
|
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
|
||||||
|
const manifest: ReleaseCandidateManifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
distSha256: assessment.candidate.distSha256,
|
||||||
|
lockfileSha256: assessment.candidate.lockfileSha256,
|
||||||
|
bundleSha256: supplyChainDigest(files),
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
await mkdir(path.join(root, "artifacts/release"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(root, "artifacts/release/release-candidate.json"),
|
||||||
|
`${JSON.stringify(manifest)}\n`,
|
||||||
|
);
|
||||||
|
return { root, manifest, assessmentSha256: digestBytes(assessmentBytes) };
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,370 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import {
|
||||||
|
chmod,
|
||||||
|
lstat,
|
||||||
|
mkdir,
|
||||||
|
mkdtemp,
|
||||||
|
readFile,
|
||||||
|
readdir,
|
||||||
|
rename,
|
||||||
|
rm,
|
||||||
|
writeFile,
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { PROMOTED_FILE_NAMES } from "../../scripts/contracts/promotion-artifacts.ts";
|
||||||
|
import { publishPrivatePromotionStaging } from "../../scripts/lib/promotion-stager.ts";
|
||||||
|
import { verifyExactPromotionBundle } from "../../scripts/lib/exact-promotion-bundle.ts";
|
||||||
|
import {
|
||||||
|
PROCESS_HEAVY_TIMEOUT_MS,
|
||||||
|
privatePromotionFiles,
|
||||||
|
syntheticSignedPromotionBundle,
|
||||||
|
} from "./security-followup-fixture.ts";
|
||||||
|
|
||||||
|
describe("security private promotion staging contracts", () => {
|
||||||
|
it("forces exact private staging modes in an isolated child with umask 077", async () => {
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), "promotion-umask-"));
|
||||||
|
try {
|
||||||
|
const stagerUrl = pathToFileURL(
|
||||||
|
path.join(process.cwd(), "scripts/lib/promotion-stager.ts"),
|
||||||
|
).href;
|
||||||
|
const contractsUrl = pathToFileURL(
|
||||||
|
path.join(process.cwd(), "scripts/contracts/promotion-artifacts.ts"),
|
||||||
|
).href;
|
||||||
|
const childPath = path.join(root, "umask-child.mjs");
|
||||||
|
const resultPath = path.join(root, "result.json");
|
||||||
|
await writeFile(resultPath, "{}\n", { mode: 0o600 });
|
||||||
|
await writeFile(
|
||||||
|
childPath,
|
||||||
|
[
|
||||||
|
`import { lstat, writeFile } from "node:fs/promises";`,
|
||||||
|
`import path from "node:path";`,
|
||||||
|
`import { createHash } from "node:crypto";`,
|
||||||
|
`import { cleanupFinalizedPromotion, publishPrivatePromotionStaging } from ${JSON.stringify(stagerUrl)};`,
|
||||||
|
`import { PROMOTED_FILE_NAMES } from ${JSON.stringify(contractsUrl)};`,
|
||||||
|
`process.umask(Number.parseInt(process.argv[2], 8));`,
|
||||||
|
`const runnerTempRoot = process.argv[3];`,
|
||||||
|
`const files = PROMOTED_FILE_NAMES.map((name) => { const bytes = Buffer.from(name); return { name, bytes, sha256: createHash("sha256").update(bytes).digest("hex") }; });`,
|
||||||
|
`const finalized = await publishPrivatePromotionStaging(runnerTempRoot, { id: "umask", attempt: 1 }, files, () => Buffer.alloc(16, 1));`,
|
||||||
|
`const directoryMode = (await lstat(finalized.stagingRoot)).mode & 0o777;`,
|
||||||
|
`const fileModes = await Promise.all(PROMOTED_FILE_NAMES.map(async (name) => (await lstat(path.join(finalized.stagingRoot, name))).mode & 0o777));`,
|
||||||
|
`await cleanupFinalizedPromotion({ runnerTempRoot, stagingRoot: finalized.stagingRoot, cleanupToken: finalized.cleanupToken, runnerTempIdentity: finalized.runnerTempIdentity, stagingIdentity: finalized.stagingIdentity });`,
|
||||||
|
`await writeFile(process.argv[4], JSON.stringify({ directoryMode, fileModes }));`,
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
const child = spawnSync(process.execPath, [childPath, "077", root, resultPath], {
|
||||||
|
cwd: root,
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
expect(child.status, `${child.stdout}\n${child.stderr}`).toBe(0);
|
||||||
|
expect(JSON.parse(await readFile(resultPath, "utf8"))).toEqual({
|
||||||
|
directoryMode: 0o700,
|
||||||
|
fileModes: [0o400, 0o400, 0o400, 0o400, 0o400],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a staged file unlinked and recreated after its original write", async () => {
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), "promotion-seal-recreate-"));
|
||||||
|
const files = privatePromotionFiles();
|
||||||
|
const token = `promotion-seal-1-${"11".repeat(16)}`;
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
publishPrivatePromotionStaging(
|
||||||
|
root,
|
||||||
|
{ id: "seal", attempt: 1 },
|
||||||
|
files,
|
||||||
|
() => Buffer.alloc(16, 0x11),
|
||||||
|
undefined,
|
||||||
|
async (name) => {
|
||||||
|
if (name !== PROMOTED_FILE_NAMES.at(-1)) return;
|
||||||
|
const first = path.join(root, token, PROMOTED_FILE_NAMES[0]);
|
||||||
|
await rm(first);
|
||||||
|
await writeFile(first, "replacement bytes\n", { mode: 0o400 });
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/staged.*digest|inode|seal/u);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects staged mode drift before returning the upload root", async () => {
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), "promotion-seal-mode-"));
|
||||||
|
const files = privatePromotionFiles();
|
||||||
|
const token = `promotion-seal-1-${"12".repeat(16)}`;
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
publishPrivatePromotionStaging(
|
||||||
|
root,
|
||||||
|
{ id: "seal", attempt: 1 },
|
||||||
|
files,
|
||||||
|
() => Buffer.alloc(16, 0x12),
|
||||||
|
undefined,
|
||||||
|
async (name) => {
|
||||||
|
if (name === PROMOTED_FILE_NAMES.at(-1)) {
|
||||||
|
await chmod(path.join(root, token, PROMOTED_FILE_NAMES[0]), 0o600);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/mode.*0400|staged.*mode|seal/u);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects staging leaf replacement between mkdir and descriptor open", async () => {
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-replace-"));
|
||||||
|
const files = privatePromotionFiles();
|
||||||
|
const token = `promotion-preopen-1-${"13".repeat(16)}`;
|
||||||
|
const displaced = path.join(root, `${token}-displaced`);
|
||||||
|
const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]);
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
publishPrivatePromotionStaging(
|
||||||
|
root,
|
||||||
|
{ id: "preopen", attempt: 1 },
|
||||||
|
files,
|
||||||
|
() => Buffer.alloc(16, 0x13),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
async (stagingRoot) => {
|
||||||
|
await rename(stagingRoot, displaced);
|
||||||
|
await mkdir(stagingRoot, { mode: 0o700 });
|
||||||
|
await writeFile(replacementCanary, "external replacement canary\n");
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/staging leaf.*changed|mkdir.*open|identity/u);
|
||||||
|
await expect(readFile(replacementCanary, "utf8")).resolves.toBe(
|
||||||
|
"external replacement canary\n",
|
||||||
|
);
|
||||||
|
await expect(readdir(displaced)).resolves.toEqual([]);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not scan a crowded parent to recover an unverified pre-open leaf", async () => {
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-bounded-"));
|
||||||
|
const files = privatePromotionFiles();
|
||||||
|
const token = `promotion-preopen-bound-1-${"14".repeat(16)}`;
|
||||||
|
const displaced = path.join(root, `${token}-displaced`);
|
||||||
|
const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]);
|
||||||
|
try {
|
||||||
|
for (let offset = 0; offset < 4_097; offset += 128) {
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(
|
||||||
|
{ length: Math.min(128, 4_097 - offset) },
|
||||||
|
(_, index) =>
|
||||||
|
mkdir(
|
||||||
|
path.join(
|
||||||
|
root,
|
||||||
|
`noise-${String(offset + index).padStart(4, "0")}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let failure: unknown;
|
||||||
|
try {
|
||||||
|
await publishPrivatePromotionStaging(
|
||||||
|
root,
|
||||||
|
{ id: "preopen-bound", attempt: 1 },
|
||||||
|
files,
|
||||||
|
() => Buffer.alloc(16, 0x14),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
async (stagingRoot) => {
|
||||||
|
await rename(stagingRoot, displaced);
|
||||||
|
await mkdir(stagingRoot, { mode: 0o700 });
|
||||||
|
await writeFile(replacementCanary, "external replacement canary\n");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
failure = error;
|
||||||
|
}
|
||||||
|
expect(failure).toBeInstanceOf(Error);
|
||||||
|
expect(failure).not.toBeInstanceOf(AggregateError);
|
||||||
|
expect((failure as Error).message).toMatch(/staging leaf.*identity/i);
|
||||||
|
await expect(readdir(displaced)).resolves.toEqual([]);
|
||||||
|
await expect(readFile(replacementCanary, "utf8")).resolves.toBe(
|
||||||
|
"external replacement canary\n",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
it("leaves a non-empty moved original untouched after pre-open mismatch", async () => {
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-nonempty-"));
|
||||||
|
const files = privatePromotionFiles();
|
||||||
|
const token = `promotion-preopen-nonempty-1-${"15".repeat(16)}`;
|
||||||
|
const displaced = path.join(root, `${token}-displaced`);
|
||||||
|
const ownedResidual = path.join(displaced, "owned-residual");
|
||||||
|
const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]);
|
||||||
|
try {
|
||||||
|
let failure: unknown;
|
||||||
|
try {
|
||||||
|
await publishPrivatePromotionStaging(
|
||||||
|
root,
|
||||||
|
{ id: "preopen-nonempty", attempt: 1 },
|
||||||
|
files,
|
||||||
|
() => Buffer.alloc(16, 0x15),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
async (stagingRoot) => {
|
||||||
|
await rename(stagingRoot, displaced);
|
||||||
|
await writeFile(ownedResidual, "owned residual\n");
|
||||||
|
await mkdir(stagingRoot, { mode: 0o700 });
|
||||||
|
await writeFile(replacementCanary, "external replacement canary\n");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
failure = error;
|
||||||
|
}
|
||||||
|
expect(failure).toBeInstanceOf(Error);
|
||||||
|
expect(failure).not.toBeInstanceOf(AggregateError);
|
||||||
|
expect((failure as Error).message).toMatch(/staging leaf.*identity/i);
|
||||||
|
await expect(readFile(ownedResidual, "utf8")).resolves.toBe(
|
||||||
|
"owned residual\n",
|
||||||
|
);
|
||||||
|
await expect(readFile(replacementCanary, "utf8")).resolves.toBe(
|
||||||
|
"external replacement canary\n",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not search outside the parent for a moved unverified original", async () => {
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-missing-"));
|
||||||
|
const outside = await mkdtemp(path.join(tmpdir(), "promotion-preopen-moved-"));
|
||||||
|
const files = privatePromotionFiles();
|
||||||
|
const token = `promotion-preopen-missing-1-${"16".repeat(16)}`;
|
||||||
|
const displaced = path.join(outside, token);
|
||||||
|
const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]);
|
||||||
|
try {
|
||||||
|
let failure: unknown;
|
||||||
|
try {
|
||||||
|
await publishPrivatePromotionStaging(
|
||||||
|
root,
|
||||||
|
{ id: "preopen-missing", attempt: 1 },
|
||||||
|
files,
|
||||||
|
() => Buffer.alloc(16, 0x16),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
async (stagingRoot) => {
|
||||||
|
await rename(stagingRoot, displaced);
|
||||||
|
await mkdir(stagingRoot, { mode: 0o700 });
|
||||||
|
await writeFile(replacementCanary, "external replacement canary\n");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
failure = error;
|
||||||
|
}
|
||||||
|
expect(failure).toBeInstanceOf(Error);
|
||||||
|
expect(failure).not.toBeInstanceOf(AggregateError);
|
||||||
|
expect((failure as Error).message).toMatch(/staging leaf.*identity/i);
|
||||||
|
await expect(lstat(displaced)).resolves.toEqual(
|
||||||
|
expect.objectContaining({ dev: expect.any(Number), ino: expect.any(Number) }),
|
||||||
|
);
|
||||||
|
await expect(readFile(replacementCanary, "utf8")).resolves.toBe(
|
||||||
|
"external replacement canary\n",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
await rm(outside, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a fresh signed exact-five bundle replayed under a different expected run", async () => {
|
||||||
|
const fixture = syntheticSignedPromotionBundle();
|
||||||
|
await expect(
|
||||||
|
verifyExactPromotionBundle(fixture.files, {
|
||||||
|
...fixture.verification,
|
||||||
|
expected: {
|
||||||
|
...fixture.verification.expected,
|
||||||
|
run: { id: "different-run", attempt: 1 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/external expected run.*mismatch|expected promotion run/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires every external expected identity variable at the exact promotion CLI", async () => {
|
||||||
|
const fixture = syntheticSignedPromotionBundle();
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), "promotion-replay-cli-"));
|
||||||
|
const bundleRoot = path.join(root, "bundle");
|
||||||
|
try {
|
||||||
|
await mkdir(bundleRoot);
|
||||||
|
for (const [name, bytes] of Object.entries(fixture.files)) {
|
||||||
|
await writeFile(path.join(bundleRoot, name), bytes);
|
||||||
|
}
|
||||||
|
await writeFile(path.join(root, "vulnerability.pem"), fixture.vulnerabilityPem);
|
||||||
|
await writeFile(path.join(root, "provenance.pem"), fixture.provenancePem);
|
||||||
|
const cliPath = path.join(process.cwd(), "scripts/verify-exact-promotion-bundle.ts");
|
||||||
|
const baseEnvironment: NodeJS.ProcessEnv = {
|
||||||
|
...process.env,
|
||||||
|
PROMOTION_BUNDLE_ROOT: bundleRoot,
|
||||||
|
VULNERABILITY_PUBLIC_KEY_PATH: "vulnerability.pem",
|
||||||
|
VULNERABILITY_KEY_ID: "synthetic-vulnerability",
|
||||||
|
PROVENANCE_PUBLIC_KEY_PATH: "provenance.pem",
|
||||||
|
PROVENANCE_KEY_ID: "synthetic-provenance",
|
||||||
|
EXPECTED_PROMOTION_RUN_ID: fixture.verification.expected.run.id,
|
||||||
|
EXPECTED_PROMOTION_RUN_ATTEMPT: String(
|
||||||
|
fixture.verification.expected.run.attempt,
|
||||||
|
),
|
||||||
|
EXPECTED_PROMOTION_SOURCE_REVISION:
|
||||||
|
fixture.verification.expected.sourceRevision,
|
||||||
|
EXPECTED_PROMOTION_ARCHIVE_SHA256:
|
||||||
|
fixture.verification.expected.archiveSha256,
|
||||||
|
};
|
||||||
|
const requiredExpected = [
|
||||||
|
"EXPECTED_PROMOTION_RUN_ID",
|
||||||
|
"EXPECTED_PROMOTION_RUN_ATTEMPT",
|
||||||
|
"EXPECTED_PROMOTION_SOURCE_REVISION",
|
||||||
|
"EXPECTED_PROMOTION_ARCHIVE_SHA256",
|
||||||
|
] as const;
|
||||||
|
for (const missing of requiredExpected) {
|
||||||
|
const environment = { ...baseEnvironment };
|
||||||
|
delete environment[missing];
|
||||||
|
const result = spawnSync(process.execPath, [cliPath], {
|
||||||
|
cwd: root,
|
||||||
|
encoding: "utf8",
|
||||||
|
env: environment,
|
||||||
|
});
|
||||||
|
expect(result.status, missing).not.toBe(0);
|
||||||
|
expect(result.stderr, missing).toContain(
|
||||||
|
`exact promotion verification environment is missing ${missing}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const [name, value, diagnostic] of [
|
||||||
|
["EXPECTED_PROMOTION_RUN_ID", "different-run", /external expected run.*mismatch/u],
|
||||||
|
["EXPECTED_PROMOTION_RUN_ATTEMPT", "2", /external expected run.*mismatch/u],
|
||||||
|
["EXPECTED_PROMOTION_SOURCE_REVISION", "f".repeat(40), /external expected source revision.*mismatch/u],
|
||||||
|
["EXPECTED_PROMOTION_ARCHIVE_SHA256", "0".repeat(64), /external expected archive digest.*mismatch/u],
|
||||||
|
] as const) {
|
||||||
|
const result = spawnSync(process.execPath, [cliPath], {
|
||||||
|
cwd: root,
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...baseEnvironment, [name]: value },
|
||||||
|
});
|
||||||
|
expect(result.status, name).not.toBe(0);
|
||||||
|
expect(result.stderr, name).toMatch(diagnostic);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}, PROCESS_HEAVY_TIMEOUT_MS);
|
||||||
|
});
|
||||||
@@ -0,0 +1,708 @@
|
|||||||
|
import { generateKeyPairSync } from "node:crypto";
|
||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
evaluatePromotionEvidence,
|
||||||
|
providerPublicKeyFingerprint,
|
||||||
|
validateProviderEvidence,
|
||||||
|
} from "../../scripts/lib/provider-evidence.ts";
|
||||||
|
import { readProviderTrust } from "../../scripts/lib/provider-trust.ts";
|
||||||
|
import { superviseProviderEvidence } from "../../scripts/lib/provider-supervisor.ts";
|
||||||
|
import { runProviderProcess } from "../../scripts/lib/provider-process-runner.ts";
|
||||||
|
import { runStageVerifiedPromotionCli } from "../../scripts/lib/stage-verified-promotion-cli.ts";
|
||||||
|
import type { ReleaseCandidateManifest } from "../../scripts/lib/release-candidate.ts";
|
||||||
|
import {
|
||||||
|
digest,
|
||||||
|
providerExpectedContext,
|
||||||
|
providerUnsigned,
|
||||||
|
signedProviderV2,
|
||||||
|
trust,
|
||||||
|
} from "./security-followup-fixture.ts";
|
||||||
|
|
||||||
|
describe("security provider evidence contracts", () => {
|
||||||
|
it("accepts signed provider v2 evidence only for the exact run, source, archive, and nonce", () => {
|
||||||
|
const now = Date.parse("2026-08-02T01:00:00.000Z");
|
||||||
|
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||||
|
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||||
|
const expected = providerExpectedContext();
|
||||||
|
const vulnerability = signedProviderV2(
|
||||||
|
{
|
||||||
|
source: expected.source,
|
||||||
|
candidate: expected.candidate,
|
||||||
|
schemaVersion: 2,
|
||||||
|
evidenceType: "vulnerability-report",
|
||||||
|
provider: "fixture-vulnerability",
|
||||||
|
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||||
|
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||||
|
run: { ...expected.run, invocationNonce: "1".repeat(64) },
|
||||||
|
secretScanAttestation: expected.secretScanAttestation,
|
||||||
|
findings: [],
|
||||||
|
},
|
||||||
|
"vulnerability-key",
|
||||||
|
vulnerabilityKeys.publicKey,
|
||||||
|
vulnerabilityKeys.privateKey,
|
||||||
|
);
|
||||||
|
const provenance = signedProviderV2(
|
||||||
|
{
|
||||||
|
source: expected.source,
|
||||||
|
candidate: expected.candidate,
|
||||||
|
schemaVersion: 2,
|
||||||
|
evidenceType: "provenance-attestation",
|
||||||
|
provider: "fixture-provenance",
|
||||||
|
signer: "fixture-workload",
|
||||||
|
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||||
|
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||||
|
run: { ...expected.run, invocationNonce: "2".repeat(64) },
|
||||||
|
subject: { name: "dist", digest: { sha256: expected.candidate.distSha256 } },
|
||||||
|
},
|
||||||
|
"provenance-key",
|
||||||
|
provenanceKeys.publicKey,
|
||||||
|
provenanceKeys.privateKey,
|
||||||
|
);
|
||||||
|
const result = evaluatePromotionEvidence({
|
||||||
|
expected: {
|
||||||
|
...expected,
|
||||||
|
vulnerabilityInvocationNonce: "1".repeat(64),
|
||||||
|
provenanceInvocationNonce: "2".repeat(64),
|
||||||
|
},
|
||||||
|
localStatus: "PASS",
|
||||||
|
vulnerabilityReport: vulnerability,
|
||||||
|
provenanceAttestation: provenance,
|
||||||
|
vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey),
|
||||||
|
provenanceTrust: trust("provenance-key", provenanceKeys.publicKey),
|
||||||
|
nowEpochMs: () => now,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: "PASS",
|
||||||
|
vulnerabilityStatus: "PASS",
|
||||||
|
provenanceAttestationStatus: "PASS",
|
||||||
|
failures: [],
|
||||||
|
});
|
||||||
|
const replayed = evaluatePromotionEvidence({
|
||||||
|
expected: {
|
||||||
|
...expected,
|
||||||
|
run: { id: expected.run.id, attempt: 2 },
|
||||||
|
vulnerabilityInvocationNonce: "1".repeat(64),
|
||||||
|
provenanceInvocationNonce: "2".repeat(64),
|
||||||
|
},
|
||||||
|
localStatus: "PASS",
|
||||||
|
vulnerabilityReport: vulnerability,
|
||||||
|
provenanceAttestation: provenance,
|
||||||
|
vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey),
|
||||||
|
provenanceTrust: trust("provenance-key", provenanceKeys.publicKey),
|
||||||
|
nowEpochMs: () => now,
|
||||||
|
});
|
||||||
|
expect(replayed.status).toBe("FAIL_UNVERIFIED");
|
||||||
|
expect(replayed.failures).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"vulnerability report run identity mismatch",
|
||||||
|
"provenance attestation run identity mismatch",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a signed vulnerability PASS when the captured SARIF attestation differs", () => {
|
||||||
|
const keys = generateKeyPairSync("ed25519");
|
||||||
|
const expected = providerExpectedContext();
|
||||||
|
const secretScanAttestation = {
|
||||||
|
status: "PASS" as const,
|
||||||
|
localEvidenceAssessmentSha256: digest("assessment"),
|
||||||
|
sourceSetSha256: expected.source.sourceSetSha256,
|
||||||
|
policySha256: digest("secret policy"),
|
||||||
|
sarifSha256: digest("real sarif"),
|
||||||
|
scanInputSha256: digest("scan input"),
|
||||||
|
};
|
||||||
|
const report = signedProviderV2(
|
||||||
|
{
|
||||||
|
schemaVersion: 2,
|
||||||
|
evidenceType: "vulnerability-report",
|
||||||
|
provider: "fixture-vulnerability",
|
||||||
|
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||||
|
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||||
|
run: { ...expected.run, invocationNonce: "1".repeat(64) },
|
||||||
|
source: expected.source,
|
||||||
|
candidate: expected.candidate,
|
||||||
|
secretScanAttestation,
|
||||||
|
findings: [],
|
||||||
|
},
|
||||||
|
"vulnerability-key",
|
||||||
|
keys.publicKey,
|
||||||
|
keys.privateKey,
|
||||||
|
);
|
||||||
|
const validated = validateProviderEvidence({
|
||||||
|
kind: "vulnerability",
|
||||||
|
value: report,
|
||||||
|
expected: {
|
||||||
|
...expected,
|
||||||
|
vulnerabilityInvocationNonce: "1".repeat(64),
|
||||||
|
provenanceInvocationNonce: "2".repeat(64),
|
||||||
|
secretScanAttestation: {
|
||||||
|
...secretScanAttestation,
|
||||||
|
sarifSha256: digest("forged empty sarif"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trust: trust("vulnerability-key", keys.publicKey),
|
||||||
|
nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"),
|
||||||
|
});
|
||||||
|
expect(validated.status).toBe("FAIL_UNVERIFIED");
|
||||||
|
expect(validated.failures).toContain(
|
||||||
|
"vulnerability report secret scan attestation mismatch",
|
||||||
|
);
|
||||||
|
const forged = structuredClone(report);
|
||||||
|
forged.secretScanAttestation.sarifSha256 = digest("forged empty sarif");
|
||||||
|
const forgedValidation = validateProviderEvidence({
|
||||||
|
kind: "vulnerability",
|
||||||
|
value: forged,
|
||||||
|
expected: {
|
||||||
|
...expected,
|
||||||
|
vulnerabilityInvocationNonce: "1".repeat(64),
|
||||||
|
provenanceInvocationNonce: "2".repeat(64),
|
||||||
|
secretScanAttestation: forged.secretScanAttestation,
|
||||||
|
},
|
||||||
|
trust: trust("vulnerability-key", keys.publicKey),
|
||||||
|
nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"),
|
||||||
|
});
|
||||||
|
expect(forgedValidation.failures).toContain(
|
||||||
|
"vulnerability report signature verification failed",
|
||||||
|
);
|
||||||
|
|
||||||
|
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||||
|
const provenance = signedProviderV2(
|
||||||
|
{
|
||||||
|
schemaVersion: 2,
|
||||||
|
evidenceType: "provenance-attestation",
|
||||||
|
provider: "fixture-provenance",
|
||||||
|
signer: "fixture-workload",
|
||||||
|
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||||
|
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||||
|
run: { ...expected.run, invocationNonce: "2".repeat(64) },
|
||||||
|
source: expected.source,
|
||||||
|
candidate: expected.candidate,
|
||||||
|
subject: { name: "dist", digest: { sha256: expected.candidate.distSha256 } },
|
||||||
|
},
|
||||||
|
"provenance-key",
|
||||||
|
provenanceKeys.publicKey,
|
||||||
|
provenanceKeys.privateKey,
|
||||||
|
);
|
||||||
|
const evaluated = evaluatePromotionEvidence({
|
||||||
|
expected: {
|
||||||
|
...expected,
|
||||||
|
vulnerabilityInvocationNonce: "1".repeat(64),
|
||||||
|
provenanceInvocationNonce: "2".repeat(64),
|
||||||
|
secretScanAttestation: {
|
||||||
|
...secretScanAttestation,
|
||||||
|
sarifSha256: digest("forged empty sarif"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
localStatus: "PASS",
|
||||||
|
vulnerabilityReport: report,
|
||||||
|
provenanceAttestation: provenance,
|
||||||
|
vulnerabilityTrust: trust("vulnerability-key", keys.publicKey),
|
||||||
|
provenanceTrust: trust("provenance-key", provenanceKeys.publicKey),
|
||||||
|
nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"),
|
||||||
|
});
|
||||||
|
expect(evaluated.vulnerabilityStatus).toBe("FAIL_UNVERIFIED");
|
||||||
|
expect(evaluated.failures).toContain(
|
||||||
|
"vulnerability report secret scan attestation mismatch",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["vulnerability", "provenance"] as const)(
|
||||||
|
"rejects correctly re-signed %s v2 context/time/replay drift",
|
||||||
|
(kind) => {
|
||||||
|
const now = Date.parse("2026-08-02T01:00:00.000Z");
|
||||||
|
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||||
|
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||||
|
const expected = providerExpectedContext();
|
||||||
|
const baseVulnerability = providerUnsigned("vulnerability", expected);
|
||||||
|
const baseProvenance = providerUnsigned("provenance", expected);
|
||||||
|
const validVulnerability = signedProviderV2(
|
||||||
|
baseVulnerability,
|
||||||
|
"vulnerability-key",
|
||||||
|
vulnerabilityKeys.publicKey,
|
||||||
|
vulnerabilityKeys.privateKey,
|
||||||
|
);
|
||||||
|
const validProvenance = signedProviderV2(
|
||||||
|
baseProvenance,
|
||||||
|
"provenance-key",
|
||||||
|
provenanceKeys.publicKey,
|
||||||
|
provenanceKeys.privateKey,
|
||||||
|
);
|
||||||
|
const rawCases: Array<readonly [
|
||||||
|
string,
|
||||||
|
(value: Record<string, any>) => Record<string, any>,
|
||||||
|
RegExp,
|
||||||
|
]> = [
|
||||||
|
["schema v1", (value) => ({ ...value, schemaVersion: 1 }), /missing or invalid/u],
|
||||||
|
[
|
||||||
|
"evidence type",
|
||||||
|
(value) => ({
|
||||||
|
...value,
|
||||||
|
evidenceType:
|
||||||
|
kind === "vulnerability"
|
||||||
|
? "provenance-attestation"
|
||||||
|
: "vulnerability-report",
|
||||||
|
}),
|
||||||
|
/missing or invalid/u,
|
||||||
|
],
|
||||||
|
...(["archiveSha256", "bundleSha256", "distSha256", "lockfileSha256"] as const).map(
|
||||||
|
(field) => [
|
||||||
|
`candidate ${field}`,
|
||||||
|
(value: Record<string, any>) => ({
|
||||||
|
...value,
|
||||||
|
candidate: { ...value.candidate, [field]: "f".repeat(64) },
|
||||||
|
...(kind === "provenance" && field === "distSha256"
|
||||||
|
? {
|
||||||
|
subject: {
|
||||||
|
name: "dist",
|
||||||
|
digest: { sha256: "f".repeat(64) },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
/candidate identity|subject dist/u,
|
||||||
|
] as const,
|
||||||
|
),
|
||||||
|
[
|
||||||
|
"different archive with same dist and lockfile",
|
||||||
|
(value) => ({
|
||||||
|
...value,
|
||||||
|
candidate: { ...value.candidate, archiveSha256: "e".repeat(64) },
|
||||||
|
}),
|
||||||
|
/candidate identity/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"source revision",
|
||||||
|
(value) => ({ ...value, source: { ...value.source, revision: "c".repeat(40) } }),
|
||||||
|
/source identity/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"source set",
|
||||||
|
(value) => ({ ...value, source: { ...value.source, sourceSetSha256: "c".repeat(64) } }),
|
||||||
|
/source identity/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"run id",
|
||||||
|
(value) => ({ ...value, run: { ...value.run, id: "other-run" } }),
|
||||||
|
/run identity/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"run attempt replay",
|
||||||
|
(value) => ({ ...value, run: { ...value.run, attempt: 2 } }),
|
||||||
|
/run identity/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"different nonce",
|
||||||
|
(value) => ({ ...value, run: { ...value.run, invocationNonce: "3".repeat(64) } }),
|
||||||
|
/invocation nonce/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"missing nonce",
|
||||||
|
(value) => {
|
||||||
|
const run = { ...value.run };
|
||||||
|
delete run.invocationNonce;
|
||||||
|
return { ...value, run };
|
||||||
|
},
|
||||||
|
/missing or invalid/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"uppercase nonce",
|
||||||
|
(value) => ({ ...value, run: { ...value.run, invocationNonce: "A".repeat(64) } }),
|
||||||
|
/missing or invalid/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"short nonce",
|
||||||
|
(value) => ({ ...value, run: { ...value.run, invocationNonce: "1".repeat(62) } }),
|
||||||
|
/missing or invalid/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"issued future boundary",
|
||||||
|
(value) => ({ ...value, issuedAt: "2026-08-02T01:05:00.001Z" }),
|
||||||
|
/future skew/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expiry equality",
|
||||||
|
(value) => ({ ...value, expiresAt: "2026-08-02T01:00:00.000Z" }),
|
||||||
|
/expired/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expiry past",
|
||||||
|
(value) => ({ ...value, expiresAt: "2026-08-02T00:59:59.999Z" }),
|
||||||
|
/expired/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"zero lifetime",
|
||||||
|
(value) => ({
|
||||||
|
...value,
|
||||||
|
issuedAt: "2026-08-02T01:01:00.000Z",
|
||||||
|
expiresAt: "2026-08-02T01:01:00.000Z",
|
||||||
|
}),
|
||||||
|
/not positive/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"negative lifetime",
|
||||||
|
(value) => ({
|
||||||
|
...value,
|
||||||
|
issuedAt: "2026-08-02T01:02:00.000Z",
|
||||||
|
expiresAt: "2026-08-02T01:01:59.999Z",
|
||||||
|
}),
|
||||||
|
/not positive/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"lifetime above two hours",
|
||||||
|
(value) => ({
|
||||||
|
...value,
|
||||||
|
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||||
|
expiresAt: "2026-08-02T03:00:00.001Z",
|
||||||
|
}),
|
||||||
|
/exceeds two hours/u,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"wrong fingerprint",
|
||||||
|
(value) => ({
|
||||||
|
...value,
|
||||||
|
signature: {
|
||||||
|
...value.signature,
|
||||||
|
publicKeyFingerprint: `sha256:${"d".repeat(64)}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/trust identity/u,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
const cases = rawCases.map(([name, mutate, failure]) => ({
|
||||||
|
name,
|
||||||
|
mutate,
|
||||||
|
failure,
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const testCase of cases) {
|
||||||
|
const base = kind === "vulnerability" ? baseVulnerability : baseProvenance;
|
||||||
|
const mutated = testCase.mutate(structuredClone(base));
|
||||||
|
const resigned = signedProviderV2(
|
||||||
|
mutated,
|
||||||
|
kind === "vulnerability" ? "vulnerability-key" : "provenance-key",
|
||||||
|
kind === "vulnerability" ? vulnerabilityKeys.publicKey : provenanceKeys.publicKey,
|
||||||
|
kind === "vulnerability" ? vulnerabilityKeys.privateKey : provenanceKeys.privateKey,
|
||||||
|
"signature" in mutated && mutated.signature?.publicKeyFingerprint
|
||||||
|
? mutated.signature.publicKeyFingerprint
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
const result = evaluatePromotionEvidence({
|
||||||
|
expected: {
|
||||||
|
...expected,
|
||||||
|
vulnerabilityInvocationNonce: "1".repeat(64),
|
||||||
|
provenanceInvocationNonce: "2".repeat(64),
|
||||||
|
},
|
||||||
|
localStatus: "PASS",
|
||||||
|
vulnerabilityReport:
|
||||||
|
kind === "vulnerability" ? resigned : validVulnerability,
|
||||||
|
provenanceAttestation:
|
||||||
|
kind === "provenance" ? resigned : validProvenance,
|
||||||
|
vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey),
|
||||||
|
provenanceTrust: trust("provenance-key", provenanceKeys.publicKey),
|
||||||
|
nowEpochMs: () => now,
|
||||||
|
});
|
||||||
|
expect(result.status, testCase.name).toBe("FAIL_UNVERIFIED");
|
||||||
|
expect(result.failures.join("\n"), testCase.name).toMatch(testCase.failure);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("canonicalizes provider fingerprints from DER SPKI across PEM wrapping and rejects Ed448", async () => {
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), "provider-fingerprint-"));
|
||||||
|
try {
|
||||||
|
const ed25519 = generateKeyPairSync("ed25519").publicKey;
|
||||||
|
const pem = ed25519.export({ type: "spki", format: "pem" }).toString();
|
||||||
|
const body = pem.replace(/-----[^-]+-----|\s/gu, "");
|
||||||
|
const wrapped = (width: number) =>
|
||||||
|
`-----BEGIN PUBLIC KEY-----\n${body.match(new RegExp(`.{1,${width}}`, "gu"))!.join("\n")}\n-----END PUBLIC KEY-----\n`;
|
||||||
|
await writeFile(path.join(root, "a.pem"), wrapped(64));
|
||||||
|
await writeFile(path.join(root, "b.pem"), wrapped(32));
|
||||||
|
const first = await readProviderTrust(root, "a.pem", "fixture-key");
|
||||||
|
const second = await readProviderTrust(root, "b.pem", "fixture-key");
|
||||||
|
expect(first?.publicKeyFingerprint).toBe(providerPublicKeyFingerprint(ed25519));
|
||||||
|
expect(second?.publicKeyFingerprint).toBe(first?.publicKeyFingerprint);
|
||||||
|
|
||||||
|
const ed448 = generateKeyPairSync("ed448").publicKey;
|
||||||
|
await writeFile(root + "/ed448.pem", ed448.export({ type: "spki", format: "pem" }));
|
||||||
|
await expect(readProviderTrust(root, "ed448.pem", "fixture-key")).resolves.toBeNull();
|
||||||
|
expect(() => providerPublicKeyFingerprint(ed448)).toThrow(/must be Ed25519/u);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures the downloaded archive pathname exactly once in the provider supervisor", async () => {
|
||||||
|
const keys = generateKeyPairSync("ed25519");
|
||||||
|
const expected = providerExpectedContext();
|
||||||
|
let captureCount = 0;
|
||||||
|
let receivedEnvironment: Readonly<Record<string, string>> | undefined;
|
||||||
|
const manifest: ReleaseCandidateManifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
distSha256: expected.candidate.distSha256,
|
||||||
|
lockfileSha256: expected.candidate.lockfileSha256,
|
||||||
|
bundleSha256: expected.candidate.bundleSha256,
|
||||||
|
files: [{ path: "pnpm-lock.yaml", bytes: 1, sha256: expected.candidate.lockfileSha256 }],
|
||||||
|
};
|
||||||
|
const result = await superviseProviderEvidence(
|
||||||
|
{
|
||||||
|
kind: "vulnerability",
|
||||||
|
archivePath: "/downloads/candidate.tar.gz",
|
||||||
|
expectedArchiveSha256: expected.candidate.archiveSha256,
|
||||||
|
expectedRun: {
|
||||||
|
id: expected.run.id,
|
||||||
|
attempt: expected.run.attempt,
|
||||||
|
sourceRevision: expected.source.revision,
|
||||||
|
},
|
||||||
|
trust: trust("vulnerability-key", keys.publicKey),
|
||||||
|
executeProvider: async ({ environment }) => {
|
||||||
|
receivedEnvironment = environment;
|
||||||
|
},
|
||||||
|
captureReport: async () => Buffer.from("{}\n"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
captureArchive: async (input) => {
|
||||||
|
captureCount += 1;
|
||||||
|
expect(input).toEqual({
|
||||||
|
archivePath: "/downloads/candidate.tar.gz",
|
||||||
|
expectedSha256: expected.candidate.archiveSha256,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
bytes: Buffer.from("captured archive"),
|
||||||
|
archiveSha256: expected.candidate.archiveSha256,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
withVerifiedCandidate: (async (input: any) =>
|
||||||
|
input.verify({ extractionRoot: "/captured/extraction", manifest })) as any,
|
||||||
|
verifyLocalEvidence: async () => ({
|
||||||
|
status: "PASS",
|
||||||
|
identity: {
|
||||||
|
sourceRevision: expected.source.revision,
|
||||||
|
sourceSetSha256: expected.source.sourceSetSha256,
|
||||||
|
assessmentSha256: digest("assessment"),
|
||||||
|
secretScan: {
|
||||||
|
policySha256: digest("provider secret policy"),
|
||||||
|
sarifSha256: digest("provider secret sarif"),
|
||||||
|
scanInputSha256: digest("provider secret input"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
failures: [],
|
||||||
|
}),
|
||||||
|
validateUpload: (async (input: any) => {
|
||||||
|
expect("archivePath" in input).toBe(false);
|
||||||
|
return { sealed: true };
|
||||||
|
}) as any,
|
||||||
|
randomBytes: () => Buffer.alloc(32, 0x11),
|
||||||
|
nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(captureCount).toBe(1);
|
||||||
|
expect(receivedEnvironment).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
PROVIDER_EVIDENCE_SCHEMA_VERSION: "2",
|
||||||
|
PROVIDER_INVOCATION_NONCE: "11".repeat(32),
|
||||||
|
PROVIDER_ISSUED_AT: "2026-08-02T01:00:00.000Z",
|
||||||
|
PROVIDER_EXPIRES_AT: "2026-08-02T02:00:00.000Z",
|
||||||
|
CI_RUN_ID: expected.run.id,
|
||||||
|
CI_RUN_ATTEMPT: "1",
|
||||||
|
SOURCE_REVISION: expected.source.revision,
|
||||||
|
CANDIDATE_ARCHIVE_SHA256: expected.candidate.archiveSha256,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.evidence).toEqual({ sealed: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("samples provider freshness after report capture instead of reusing issuance time", async () => {
|
||||||
|
const keys = generateKeyPairSync("ed25519");
|
||||||
|
const expected = providerExpectedContext();
|
||||||
|
const manifest: ReleaseCandidateManifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
distSha256: expected.candidate.distSha256,
|
||||||
|
lockfileSha256: expected.candidate.lockfileSha256,
|
||||||
|
bundleSha256: expected.candidate.bundleSha256,
|
||||||
|
files: [{ path: "pnpm-lock.yaml", bytes: 1, sha256: expected.candidate.lockfileSha256 }],
|
||||||
|
};
|
||||||
|
const issuedSample = Date.parse("2026-08-02T01:00:00.000Z");
|
||||||
|
const validationSample = Date.parse("2026-08-02T02:00:00.001Z");
|
||||||
|
const samples = [issuedSample, validationSample];
|
||||||
|
let issuedAt = "";
|
||||||
|
await expect(
|
||||||
|
superviseProviderEvidence(
|
||||||
|
{
|
||||||
|
kind: "vulnerability",
|
||||||
|
archivePath: "/downloads/candidate.tar.gz",
|
||||||
|
expectedArchiveSha256: expected.candidate.archiveSha256,
|
||||||
|
expectedRun: {
|
||||||
|
id: expected.run.id,
|
||||||
|
attempt: expected.run.attempt,
|
||||||
|
sourceRevision: expected.source.revision,
|
||||||
|
},
|
||||||
|
trust: trust("vulnerability-key", keys.publicKey),
|
||||||
|
executeProvider: async ({ environment }) => {
|
||||||
|
issuedAt = environment.PROVIDER_ISSUED_AT!;
|
||||||
|
},
|
||||||
|
captureReport: async () => Buffer.from("{}\n"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
captureArchive: async () => ({
|
||||||
|
bytes: Buffer.from("captured archive"),
|
||||||
|
archiveSha256: expected.candidate.archiveSha256,
|
||||||
|
}),
|
||||||
|
withVerifiedCandidate: (async (input: any) =>
|
||||||
|
input.verify({ extractionRoot: "/captured/extraction", manifest })) as any,
|
||||||
|
verifyLocalEvidence: async () => ({
|
||||||
|
status: "PASS",
|
||||||
|
identity: {
|
||||||
|
sourceRevision: expected.source.revision,
|
||||||
|
sourceSetSha256: expected.source.sourceSetSha256,
|
||||||
|
assessmentSha256: digest("assessment"),
|
||||||
|
secretScan: {
|
||||||
|
policySha256: digest("provider secret policy"),
|
||||||
|
sarifSha256: digest("provider secret sarif"),
|
||||||
|
scanInputSha256: digest("provider secret input"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
failures: [],
|
||||||
|
}),
|
||||||
|
validateUpload: (async (input: any) => {
|
||||||
|
expect(input.nowEpochMs()).toBe(validationSample);
|
||||||
|
throw new Error("provider report expired during execution");
|
||||||
|
}) as any,
|
||||||
|
randomBytes: () => Buffer.alloc(32, 0x33),
|
||||||
|
nowEpochMs: () => samples.shift()!,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/expired during execution/u);
|
||||||
|
expect(issuedAt).toBe("2026-08-02T01:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kills a timed-out provider but settles only after the child closes", async () => {
|
||||||
|
const child = new EventEmitter() as EventEmitter & {
|
||||||
|
kill(signal: NodeJS.Signals): boolean;
|
||||||
|
};
|
||||||
|
let killedWith: NodeJS.Signals | undefined;
|
||||||
|
child.kill = (signal) => {
|
||||||
|
killedWith = signal;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let fireTimeout: (() => void) | undefined;
|
||||||
|
let settled = false;
|
||||||
|
const running = runProviderProcess(
|
||||||
|
{ executable: "/usr/bin/bwrap", arguments: [], environment: {}, timeoutMs: 1 },
|
||||||
|
{
|
||||||
|
spawnChild: () => child as any,
|
||||||
|
setTimer: (callback) => {
|
||||||
|
fireTimeout = callback;
|
||||||
|
return 1 as any;
|
||||||
|
},
|
||||||
|
clearTimer: () => undefined,
|
||||||
|
},
|
||||||
|
).finally(() => {
|
||||||
|
settled = true;
|
||||||
|
});
|
||||||
|
fireTimeout?.();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(killedWith).toBe("SIGKILL");
|
||||||
|
expect(settled).toBe(false);
|
||||||
|
child.emit("close", null, "SIGKILL");
|
||||||
|
await expect(running).rejects.toThrow(/timed out/u);
|
||||||
|
expect(settled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures process-group kill errors, attempts child fallback, and settles after close", async () => {
|
||||||
|
const child = new EventEmitter() as EventEmitter & {
|
||||||
|
pid: number;
|
||||||
|
kill(signal: NodeJS.Signals): boolean;
|
||||||
|
};
|
||||||
|
child.pid = 12_346;
|
||||||
|
let fallbackSignal: NodeJS.Signals | undefined;
|
||||||
|
child.kill = (signal) => {
|
||||||
|
fallbackSignal = signal;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let fireTimeout: (() => void) | undefined;
|
||||||
|
const running = runProviderProcess(
|
||||||
|
{ executable: "/usr/bin/bwrap", arguments: [], environment: {}, timeoutMs: 1 },
|
||||||
|
{
|
||||||
|
spawnChild: () => child as any,
|
||||||
|
setTimer: (callback) => {
|
||||||
|
fireTimeout = callback;
|
||||||
|
return 1 as any;
|
||||||
|
},
|
||||||
|
clearTimer: () => undefined,
|
||||||
|
killProcessGroup: () => {
|
||||||
|
throw Object.assign(new Error("group kill denied"), { code: "EPERM" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(() => fireTimeout?.()).not.toThrow();
|
||||||
|
expect(fallbackSignal).toBe("SIGKILL");
|
||||||
|
child.emit("close", null, "SIGKILL");
|
||||||
|
await expect(running).rejects.toThrow(/timed out.*kill failed.*close/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["open failure", "partial write failure"])(
|
||||||
|
"cleans finalized staging from memory when GITHUB_OUTPUT has a %s",
|
||||||
|
async (failureKind) => {
|
||||||
|
const finalized = {
|
||||||
|
stagingRoot: "/runner/promotion-run-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
cleanupToken: "promotion-run-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
runnerTempIdentity: { dev: 10, ino: 20 },
|
||||||
|
stagingIdentity: { dev: 30, ino: 40 },
|
||||||
|
files: [],
|
||||||
|
} as const;
|
||||||
|
let cleanupInput: unknown;
|
||||||
|
let appendCalls = 0;
|
||||||
|
const environment = {
|
||||||
|
CANDIDATE_ARCHIVE_PATH: "candidate.tar.gz",
|
||||||
|
CANDIDATE_ARCHIVE_SHA256: "a".repeat(64),
|
||||||
|
VULNERABILITY_REPORT_PATH: "vulnerability.json",
|
||||||
|
PROVENANCE_ATTESTATION_PATH: "provenance.json",
|
||||||
|
VULNERABILITY_PUBLIC_KEY_PATH: "vulnerability.pem",
|
||||||
|
VULNERABILITY_KEY_ID: "vulnerability-key",
|
||||||
|
PROVENANCE_PUBLIC_KEY_PATH: "provenance.pem",
|
||||||
|
PROVENANCE_KEY_ID: "provenance-key",
|
||||||
|
CI_RUN_ID: "run",
|
||||||
|
CI_RUN_ATTEMPT: "1",
|
||||||
|
VITE_COMMIT_SHA: "b".repeat(40),
|
||||||
|
VULNERABILITY_INVOCATION_NONCE: "c".repeat(64),
|
||||||
|
PROVENANCE_INVOCATION_NONCE: "d".repeat(64),
|
||||||
|
RUNNER_TEMP: "/runner",
|
||||||
|
GITHUB_OUTPUT: "/runner/github-output",
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
runStageVerifiedPromotionCli(environment, {
|
||||||
|
cwd: () => "/workspace",
|
||||||
|
finalize: async () => finalized as any,
|
||||||
|
appendOutput: async () => {
|
||||||
|
appendCalls += 1;
|
||||||
|
if (failureKind === "partial write failure") {
|
||||||
|
// The output sink accepted an unspecified prefix before rejecting.
|
||||||
|
}
|
||||||
|
throw new Error(failureKind);
|
||||||
|
},
|
||||||
|
cleanup: async (input) => {
|
||||||
|
cleanupInput = input;
|
||||||
|
},
|
||||||
|
writeStdout: () => undefined,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(new RegExp(failureKind, "u"));
|
||||||
|
expect(appendCalls).toBe(1);
|
||||||
|
expect(cleanupInput).toEqual({
|
||||||
|
runnerTempRoot: "/runner",
|
||||||
|
stagingRoot: finalized.stagingRoot,
|
||||||
|
cleanupToken: finalized.cleanupToken,
|
||||||
|
runnerTempIdentity: finalized.runnerTempIdentity,
|
||||||
|
stagingIdentity: finalized.stagingIdentity,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
});
|
||||||
@@ -182,8 +182,8 @@ describe("selective Task 3 contract closure", () => {
|
|||||||
it.skipIf(isReducedCiContractRun())("accepts only the canonical exact-count authority and rejects orphan retention", async () => {
|
it.skipIf(isReducedCiContractRun())("accepts only the canonical exact-count authority and rejects orphan retention", async () => {
|
||||||
const canonical = await loadCiGateContract(process.cwd());
|
const canonical = await loadCiGateContract(process.cwd());
|
||||||
expect(canonical.gates).toHaveLength(27);
|
expect(canonical.gates).toHaveLength(27);
|
||||||
expect(canonical.commands).toHaveLength(84);
|
expect(canonical.commands).toHaveLength(91);
|
||||||
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(96);
|
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(103);
|
||||||
expect(canonical.artifacts).toHaveLength(109);
|
expect(canonical.artifacts).toHaveLength(109);
|
||||||
expect(canonical.stages).toHaveLength(5);
|
expect(canonical.stages).toHaveLength(5);
|
||||||
expect(canonical.retention.classes).toHaveLength(5);
|
expect(canonical.retention.classes).toHaveLength(5);
|
||||||
|
|||||||
Reference in New Issue
Block a user