@hanamesh/dsh-app-host 0.2.0-rc.2 → 0.6.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +4 -7
- package/README.md +140 -13
- package/consistency.json +41 -6
- package/dist/catalog/fixtures/consumer.json +35 -0
- package/dist/catalog/fixtures/provider.json +107 -0
- package/dist/catalog/suite.d.ts +4 -0
- package/dist/catalog/suite.js +50 -0
- package/dist/client-ui.d.ts +21 -3
- package/dist/client-ui.js +961 -94
- package/dist/client.js +2 -2
- package/dist/control-origin.js +9 -0
- package/dist/descriptor.js +6 -0
- package/dist/dsh-compat.js +6 -0
- package/dist/dsh.d.ts +21 -4
- package/dist/dsh.js +149 -58
- package/dist/gateway.js +140 -11
- package/dist/git-process.js +64 -0
- package/dist/git-worker.js +50 -0
- package/dist/index.d.ts +9 -7
- package/dist/install-target/fixtures/catalog.json +58 -0
- package/dist/install-target/fixtures/consumer-cases.json +148 -0
- package/dist/install-target/fixtures/install-failure-cases.json +58 -0
- package/dist/install-target/fixtures/provider-cases.json +618 -0
- package/dist/install-target/fixtures/reference-consumer.js +37 -0
- package/dist/install-target/fixtures/source-profile.js +123 -0
- package/dist/install-target/harness.js +43 -0
- package/dist/install-target/run.js +28 -0
- package/dist/install-target/suite.d.ts +19 -0
- package/dist/install-target/suite.js +328 -0
- package/dist/installation-status.d.ts +21 -0
- package/dist/installation-status.js +2 -0
- package/dist/library/catalog.js +163 -19
- package/dist/library/client-identity.js +31 -0
- package/dist/library/desktop-cli.js +7 -0
- package/dist/library/domain.js +3 -1
- package/dist/library/handoff.js +246 -0
- package/dist/library/install.js +138 -32
- package/dist/library/installation-cli.js +48 -0
- package/dist/library/installation-command-worker.js +27 -0
- package/dist/library/installation-status-http.js +26 -0
- package/dist/library/installation-status.js +91 -0
- package/dist/library/installation-worker.js +44 -0
- package/dist/library/locate.js +70 -9
- package/dist/library/node-binding.js +11 -0
- package/dist/library/recovery.js +66 -0
- package/dist/library/routes.js +90 -14
- package/dist/library/service.js +123 -27
- package/dist/library/suite.js +130 -0
- package/dist/manager.js +24 -8
- package/dist/node-runtime/domain.js +6 -0
- package/dist/node-runtime/releases.json +59 -0
- package/dist/node-runtime/service.js +101 -0
- package/dist/owned-probe-diagnostic.js +53 -0
- package/dist/router/receipts.js +8 -11
- package/dist/router/routes.js +7 -7
- package/dist/routes.js +9 -8
- package/dist/runtime.js +11 -1
- package/dist/usage-evidence.js +18 -86
- package/dist/workspace-origin.js +17 -0
- package/docs/APP_PACKAGE.md +4 -0
- package/docs/CONTRACT.md +75 -11
- package/docs/DSH_COMPATIBILITY.md +94 -0
- package/docs/DSH_INTEGRATION.md +41 -0
- package/docs/INSTALLATION_STATUS.md +96 -0
- package/docs/INSTALL_TARGET.md +130 -0
- package/docs/LIBRARY.md +44 -3
- package/docs/LICENSES.json +189 -61
- package/docs/PROVENANCE.json +53 -12
- package/docs/SECURITY.md +31 -0
- package/node_modules/@hanamesh/lib-provision/LICENSE +15 -0
- package/node_modules/@hanamesh/lib-provision/README.md +134 -0
- package/node_modules/@hanamesh/lib-provision/THIRD_PARTY_NOTICES.md +17 -0
- package/node_modules/@hanamesh/lib-provision/bin/hanamesh-provision.js +13 -0
- package/node_modules/@hanamesh/lib-provision/consistency.json +20 -0
- package/node_modules/@hanamesh/lib-provision/contract/CHANGELOG.md +5 -0
- package/node_modules/@hanamesh/lib-provision/contract/conformance.mjs +80 -0
- package/node_modules/@hanamesh/lib-provision/contract/fixtures/consumer.json +9 -0
- package/node_modules/@hanamesh/lib-provision/contract/fixtures/provider.json +11 -0
- package/node_modules/@hanamesh/lib-provision/contract/lib-provision.schema.json +47 -0
- package/node_modules/@hanamesh/lib-provision/package.json +50 -0
- package/package.json +81 -33
- package/schemas/catalog-provider-page.schema.json +884 -7
- package/schemas/catalog-source.schema.json +9 -7
- package/schemas/install-target.schema.json +298 -0
- package/dist/provision/LICENSE +0 -6
- package/dist/provision/THIRD_PARTY_NOTICES.md +0 -13
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/archive.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/archive.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/check.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/check.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/checkpoints.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/checkpoints.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/cli.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/cli.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/download.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/download.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/errors.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/errors.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/extract.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/extract.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/fsutil.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/fsutil.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/index.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/index.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/ledger.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/ledger.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/manifest.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/manifest.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/paths.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/paths.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/platform.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/platform.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/provision.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/provision.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/tar.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/tar.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/types.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/types.js +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/zip.d.ts +0 -0
- /package/{dist/provision → node_modules/@hanamesh/lib-provision/lib}/zip.js +0 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consumer fixture for the install-target contract: a caller that hands a catalog identity to the market by normal
|
|
3
|
+
* navigation of its own authenticated workspace. Every rule is read from the shipped schema, not restated here.
|
|
4
|
+
* This is a test fixture for conformance runs, not a product encoding API.
|
|
5
|
+
*/
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
import { validate } from '../suite.js';
|
|
8
|
+
|
|
9
|
+
const schema=JSON.parse(await readFile(new URL('../../../schemas/install-target.schema.json',import.meta.url),'utf8'));
|
|
10
|
+
const contract=schema['x-hanamesh-contract'];
|
|
11
|
+
|
|
12
|
+
/** @param {{contractVersion?:string}} [options] Omit contractVersion for an unversioned (v1) caller. */
|
|
13
|
+
export function createReferenceConsumer(options={}){
|
|
14
|
+
const contractVersion='contractVersion' in options?options.contractVersion:contract.version;
|
|
15
|
+
if(contractVersion!==undefined&&!contract.supported.includes(contractVersion))throw new Error('Unsupported install-target contract version '+JSON.stringify(contractVersion));
|
|
16
|
+
return{
|
|
17
|
+
name:'reference-consumer'+(contractVersion===undefined?'-unversioned':'-v'+contractVersion),
|
|
18
|
+
contractVersion,
|
|
19
|
+
navigate(workspaceUrl,target){
|
|
20
|
+
const fields={...target,...(contractVersion===undefined?{}:{contractVersion})};
|
|
21
|
+
if(validate(schema,fields).length)return{ok:false,code:'INVALID_INSTALL_TARGET'};
|
|
22
|
+
const url=new URL(workspaceUrl);url.hash=contract.fragmentPrefix.slice(1)+new URLSearchParams(fields);
|
|
23
|
+
return{ok:true,url:url.href};
|
|
24
|
+
},
|
|
25
|
+
/** rc.10: present a library.install-failed event; only a schema-valid restored/not-needed recovery counts as restored. */
|
|
26
|
+
presentInstallFailure(event){
|
|
27
|
+
const recovery=event?.recovery,known=recovery&&!validate(schema,recovery,'#/$defs/recovery').length;
|
|
28
|
+
if(!known)return{installed:false,restored:false,code:event?.code,text:`安装失败:${event?.code};恢复结果未知`};
|
|
29
|
+
const restored=recovery.status==='restored'||recovery.status==='not-needed',residue=recovery.residue.map(row=>row.item);
|
|
30
|
+
return{installed:false,restored,code:event.code,residue,...(restored&&recovery.mode==='upgrade'?{version:recovery.previousVersion}:{}),
|
|
31
|
+
text:restored?`安装失败:${event.code};已恢复${recovery.mode==='upgrade'?'到原版本 '+recovery.previousVersion:'到安装前状态'}`:`安装失败:${event.code};恢复失败,未恢复:${residue.join('、')}`};
|
|
32
|
+
},
|
|
33
|
+
handshake(declaration){
|
|
34
|
+
const compatible=declaration&&!validate(schema,declaration,'#/$defs/providerDeclaration').length&&declaration.contract===contract.name&&declaration.supported.includes(contractVersion);
|
|
35
|
+
return compatible?{ok:true}:{ok:false,code:'CONTRACT_VERSION_UNSUPPORTED'};
|
|
36
|
+
}};
|
|
37
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SOURCE fixture for the install-recovery contract (rc.10): a temporary profile driven by a stand-in for the official
|
|
3
|
+
* `dsh plugin` command, an in-memory registry, and a stand-in for lib-provision with the same ledger/ownership roles.
|
|
4
|
+
* It writes package.json, a deterministic pnpm-lock.yaml, node_modules and runtime trees under its own temp root only.
|
|
5
|
+
* Failures are injected as the original error types (`DSH_PLUGIN_FAILED` exit status, lib-provision `E_*` codes).
|
|
6
|
+
* This is a conformance fixture: it is not the official CLI, not pnpm, and its results never stand for a real install.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdtemp, mkdir, readFile, writeFile, rm, readdir } from 'node:fs/promises';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join, dirname } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const exists=async path=>{try{await readFile(path);return true;}catch(error){if(error.code==='ENOENT')return false;throw error;}};
|
|
13
|
+
const readJson=async(path,fallback)=>{try{return JSON.parse(await readFile(path,'utf8'));}catch(error){if(error.code==='ENOENT')return fallback;throw error;}};
|
|
14
|
+
const writeJson=async(path,value)=>{await mkdir(dirname(path),{recursive:true});await writeFile(path,JSON.stringify(value,undefined,2)+'\n');};
|
|
15
|
+
|
|
16
|
+
/** A runtime manifest in the lib-provision shape, for one item and one fixture platform asset. */
|
|
17
|
+
export function runtimeManifest(item,version){
|
|
18
|
+
return{schema:1,sources:[{id:'fixture',kind:'file',base:'file:///fixture/'}],items:[{id:item,version,kind:'tar.gz',installTo:item,
|
|
19
|
+
platforms:{'source-fixture':{asset:`${item}-${version}.tar.gz`,sha256:Buffer.from(`${item}@${version}`).toString('hex').padEnd(64,'0').slice(0,64)}}}]};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Application package versions: `app.json` with an owned deployment and an optional runtime; `invalid` ships a broken definition. */
|
|
23
|
+
export function applicationVersion({appId,version,runtime=true,invalid=false}){
|
|
24
|
+
const deployment={id:'local',dataId:'data-v1',mode:'owned',args:['{{dataDir}}','{{port}}'],env:{},envAllowlist:[],readiness:{path:'/',status:200,bodyIncludes:appId},
|
|
25
|
+
...(runtime?{runtime:{manifest:runtimeManifest('runtime',version),item:'runtime',exec:'bin/app'}}:{})};
|
|
26
|
+
return{version,app:invalid?{id:appId}:{id:appId,name:'Fixture '+appId,deployments:[deployment]}};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function createSourceProfile({registry,profileName='source-fixture'}){
|
|
30
|
+
const root=await mkdtemp(join(tmpdir(),'hm-install-recovery-'));
|
|
31
|
+
const profileDir=join(root,'profile'),dataRoot=join(root,'data');
|
|
32
|
+
await mkdir(profileDir,{recursive:true});await mkdir(dataRoot,{recursive:true});
|
|
33
|
+
registry={'@fixture/unrelated':[{version:'2.0.0',bundle:true}],'@fixture/concurrent':[{version:'1.0.0'}],...registry};
|
|
34
|
+
await writeJson(join(profileDir,'package.json'),{name:'dsh-profile',private:true,dependencies:{},dsh:{profile:{bundles:[]}}});
|
|
35
|
+
const failures=[];const calls=[];const writers=[];
|
|
36
|
+
const manifestPath=join(profileDir,'package.json'),lockPath=join(profileDir,'pnpm-lock.yaml');
|
|
37
|
+
const moduleDir=name=>join(profileDir,'node_modules',...name.split('/'));
|
|
38
|
+
const resolved=async name=>(await readJson(join(moduleDir(name),'package.json'),{})).version;
|
|
39
|
+
async function writeLock(manifest){
|
|
40
|
+
const lines=["lockfileVersion: '9.0'",'','importers:',' .:',' dependencies:'];
|
|
41
|
+
for(const name of Object.keys(manifest.dependencies??{}).sort())lines.push(` '${name}':`,` specifier: ${manifest.dependencies[name]}`,` version: ${await resolved(name)}`);
|
|
42
|
+
await writeFile(lockPath,lines.join('\n')+'\n');
|
|
43
|
+
}
|
|
44
|
+
async function placeModule(name,version){
|
|
45
|
+
const entry=registry[name]?.find(row=>row.version===version);if(!entry)throw new Error(`fixture registry has no ${name}@${version}`);
|
|
46
|
+
await rm(moduleDir(name),{recursive:true,force:true});
|
|
47
|
+
await writeJson(join(moduleDir(name),'package.json'),{name,version,...(entry.app?{hanamesh:{app:'app.json'}}:{}),...(entry.bundle?{dsh:{bundle:{patch:'./bundle.yml'}}}:{})});
|
|
48
|
+
if(entry.app)await writeJson(join(moduleDir(name),'app.json'),entry.app);
|
|
49
|
+
return entry;
|
|
50
|
+
}
|
|
51
|
+
/** One injected failure per matching call; `skip` lets the first matching calls through (e.g. fail only the compensation). */
|
|
52
|
+
const take=(command,phase)=>{const index=failures.findIndex(row=>row.command===command&&row.phase===phase);if(index<0)return undefined;if(failures[index].skip>0){failures[index].skip--;return undefined;}return failures.splice(index,1)[0];};
|
|
53
|
+
const failed=reason=>({code:1,signal:null,stdout:'',stderr:`dsh: plugin command failed (source fixture: ${reason})`});
|
|
54
|
+
/** Stand-in for `node <bin.js> plugin --profile <name> <pnpm args>`; package files change only through here. */
|
|
55
|
+
async function spawn(command,args){
|
|
56
|
+
const [bin,plugin,flag,name,verb,...rest]=args;
|
|
57
|
+
if(plugin!=='plugin'||flag!=='--profile'||name!==profileName)throw new Error('unexpected command '+JSON.stringify([command,...args]));
|
|
58
|
+
calls.push([verb,...rest]);
|
|
59
|
+
if(take(verb,'before'))return failed(verb+' failed before writing');
|
|
60
|
+
const manifest=await readJson(manifestPath,{});manifest.dependencies??={};const bundles=manifest.dsh?.profile?.bundles??[];
|
|
61
|
+
if(verb==='add'){
|
|
62
|
+
const spec=rest.filter(arg=>!arg.startsWith('--'))[0],at=spec.lastIndexOf('@'),pkg=spec.slice(0,at),version=spec.slice(at+1);
|
|
63
|
+
const entry=await placeModule(pkg,version);const added=!(pkg in manifest.dependencies);manifest.dependencies[pkg]=version;
|
|
64
|
+
if(added&&entry.bundle&&!bundles.includes(pkg))bundles.push(pkg);
|
|
65
|
+
}else if(verb==='remove'){
|
|
66
|
+
const pkg=rest[0];if(!(pkg in manifest.dependencies))return failed(pkg+' is not a dependency');
|
|
67
|
+
delete manifest.dependencies[pkg];await rm(moduleDir(pkg),{recursive:true,force:true});
|
|
68
|
+
const index=bundles.indexOf(pkg);if(index>=0)bundles.splice(index,1);
|
|
69
|
+
}else if(verb==='install'){
|
|
70
|
+
for(const scope of await readdir(join(profileDir,'node_modules')).catch(()=>[]))
|
|
71
|
+
for(const pkg of scope.startsWith('@')?(await readdir(join(profileDir,'node_modules',scope))).map(child=>scope+'/'+child):[scope])
|
|
72
|
+
if(!(pkg in manifest.dependencies))await rm(moduleDir(pkg),{recursive:true,force:true});
|
|
73
|
+
}else throw new Error('unsupported fixture verb '+verb);
|
|
74
|
+
manifest.dsh={...manifest.dsh,profile:{...manifest.dsh?.profile,bundles}};
|
|
75
|
+
await writeJson(manifestPath,manifest);await writeLock(manifest);
|
|
76
|
+
for(const writer of writers.splice(0))await writer();
|
|
77
|
+
if(take(verb,'after'))return failed(verb+' failed after writing');
|
|
78
|
+
return{code:0,signal:null,stdout:'',stderr:''};
|
|
79
|
+
}
|
|
80
|
+
/** Concurrent writer: another `dsh plugin add` lands between this operation's commands. */
|
|
81
|
+
const concurrentAdd=(name,version)=>writers.push(async()=>{const manifest=await readJson(manifestPath,{});await placeModule(name,version);manifest.dependencies[name]=version;await writeJson(manifestPath,manifest);await writeLock(manifest);});
|
|
82
|
+
const ledgerFile=root=>join(root,'.provision','ledger.json');
|
|
83
|
+
const ledger=async root=>await readJson(ledgerFile(root),{schema:1,items:{}});
|
|
84
|
+
const provisionError=(code,item)=>Object.assign(new Error(`source fixture provision ${code}`),{name:'ProvisionError',code,item});
|
|
85
|
+
async function provision(manifest,{root,only}){
|
|
86
|
+
const results=[];
|
|
87
|
+
for(const item of manifest.items.filter(row=>!only||only.includes(row.id))){
|
|
88
|
+
if(take('provision','before'))throw provisionError('E_DOWNLOAD',item.id);
|
|
89
|
+
const asset=Object.values(item.platforms)[0],target=join(root,item.installTo),data=await ledger(root);
|
|
90
|
+
await rm(target,{recursive:true,force:true});await mkdir(target,{recursive:true});await writeFile(join(target,'VERSION'),item.version);
|
|
91
|
+
await writeJson(target+'.manifest.json',{schema:1,item:item.id,version:item.version,files:[{path:'VERSION'}]});
|
|
92
|
+
data.items[item.id]={version:item.version,sha256:asset.sha256,platform:'source-fixture',installTo:item.installTo,target,installedAt:new Date().toISOString(),source:'fixture'};
|
|
93
|
+
await writeJson(ledgerFile(root),data);
|
|
94
|
+
if(take('provision','unowned'))await writeFile(join(target,'unowned.txt'),'not listed in ownership');
|
|
95
|
+
if(take('provision','after'))throw provisionError('E_VERIFY',item.id);
|
|
96
|
+
results.push({item:item.id,version:item.version,platform:'source-fixture',target,action:'installed'});
|
|
97
|
+
}
|
|
98
|
+
return results;
|
|
99
|
+
}
|
|
100
|
+
async function remove(itemId,{root}){
|
|
101
|
+
if(take('remove','before'))throw provisionError('E_NOT_INSTALLED',itemId);
|
|
102
|
+
const data=await ledger(root),entry=data.items[itemId];if(!entry)throw provisionError('E_NOT_INSTALLED',itemId);
|
|
103
|
+
const owned=await readJson(entry.target+'.manifest.json',{files:[]});
|
|
104
|
+
for(const file of owned.files)await rm(join(entry.target,file.path),{force:true});
|
|
105
|
+
await rm(entry.target+'.manifest.json',{force:true});
|
|
106
|
+
const kept=await readdir(entry.target).catch(()=>[]);if(kept.length===0)await rm(entry.target,{recursive:true,force:true});
|
|
107
|
+
delete data.items[itemId];await writeJson(ledgerFile(root),data);
|
|
108
|
+
return{item:itemId,target:entry.target,kept};
|
|
109
|
+
}
|
|
110
|
+
/** Places an already-installed version through the same stand-in, then provisions its runtime: the upgrade baseline. */
|
|
111
|
+
async function seed(name,version){
|
|
112
|
+
const result=await spawn('node',['bin.js','plugin','--profile',profileName,'add','--save-exact',`${name}@${version}`]);if(result.code!==0)throw new Error('seed failed');
|
|
113
|
+
const entry=registry[name].find(row=>row.version===version),runtime=entry.app?.deployments?.[0]?.runtime;
|
|
114
|
+
if(runtime)await provision(runtime.manifest,{root:join(dataRoot,'runtimes',entry.app.id),only:[runtime.item]});
|
|
115
|
+
calls.length=0;
|
|
116
|
+
}
|
|
117
|
+
await seed('@fixture/unrelated','2.0.0');
|
|
118
|
+
return{root,profileDir,profileName,dataRoot,spawn,provision,remove,ledger,seed,calls,
|
|
119
|
+
fail:(command,phase,skip=0)=>failures.push({command,phase,skip}),concurrentAdd,
|
|
120
|
+
fetchImpl:latest=>async url=>{const name=decodeURIComponent(new URL(url).pathname.split('/').slice(-2,-1)[0]);const version=latest[name];
|
|
121
|
+
return version?new Response(JSON.stringify({version}),{headers:{'content-type':'application/json'}}):new Response('{"error":"not found"}',{status:404,headers:{'content-type':'application/json'}});},
|
|
122
|
+
exists,cleanup:()=>rm(root,{recursive:true,force:true})};
|
|
123
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Loads this package's real client module with a minimal hook stand-in; no production globals are modified. */
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
export const settle=async()=>{for(let i=0;i<40;i++)await Promise.resolve();};
|
|
5
|
+
|
|
6
|
+
/** Timers only advance when asked, so market polling never runs on its own during a suite. */
|
|
7
|
+
function frozenClock(){let now=1_700_000_000_000,sequence=0;const timers=new Map();
|
|
8
|
+
return{Date:{now:()=>now},setTimeout(fn,ms){const id=++sequence;timers.set(id,{fn,at:now+ms});return id;},clearTimeout(id){timers.delete(id);},
|
|
9
|
+
/** rc.10: advance the frozen clock and run the timers now due (e.g. one market event poll). */
|
|
10
|
+
advance(ms){now+=ms;for(const [id,timer] of [...timers])if(timer.at<=now){timers.delete(id);timer.fn();}}};}
|
|
11
|
+
|
|
12
|
+
export async function loadClient({fetch:transport}){
|
|
13
|
+
// Real HTTP replies arrive on later macrotasks; track them so a step completes only when the market is idle.
|
|
14
|
+
let inflight=0;const fetch=async(...args)=>{inflight++;try{const response=await transport(...args),body=await response.text();return{ok:response.ok,status:response.status,json:async()=>JSON.parse(body)};}finally{inflight--;}};
|
|
15
|
+
const idle=async()=>{do{await settle();await new Promise(r=>setImmediate(r));}while(inflight>0);await settle();};
|
|
16
|
+
const source=await readFile(new URL('../client-ui.js',import.meta.url),'utf8');
|
|
17
|
+
let loaded,tree,cursor=0,dead=false,scheduled=false;const hooks=[],pendingEffects=[],disposers=[];
|
|
18
|
+
const schedule=()=>{if(!dead&&!scheduled){scheduled=true;queueMicrotask(()=>{scheduled=false;if(!dead)render();});}};
|
|
19
|
+
const React={createElement:(type,props,...children)=>({type,props:props??{},children}),
|
|
20
|
+
useState(initial){const id=cursor++;if(!hooks[id])hooks[id]={value:typeof initial==='function'?initial():initial};return[hooks[id].value,next=>{hooks[id].value=typeof next==='function'?next(hooks[id].value):next;schedule();}];},
|
|
21
|
+
useRef(initial){const id=cursor++;return hooks[id]??=({current:initial});},
|
|
22
|
+
useCallback(fn,deps){const id=cursor++,old=hooks[id];if(!old||deps.some((v,i)=>v!==old.deps[i]))hooks[id]={value:fn,deps};return hooks[id].value;},
|
|
23
|
+
useEffect(run,deps){const id=cursor++,old=hooks[id];if(!old||deps.some((v,i)=>v!==old.deps[i])){hooks[id]={deps,cleanup:old?.cleanup};pendingEffects.push(()=>{hooks[id].cleanup?.();hooks[id].cleanup=run();});}}};
|
|
24
|
+
const listeners=new Map(),document={visibilityState:'visible',createElement:()=>({textContent:'',remove(){}}),head:{append(){}},addEventListener(){},removeEventListener(){}};
|
|
25
|
+
const window={__ModuleLoader__:{load:module=>{loaded=module;}},self:{},top:{},parent:{postMessage(){}}};
|
|
26
|
+
const clock=frozenClock();
|
|
27
|
+
const scope={window,fetch,location:{hash:''},crypto:globalThis.crypto,console,URLSearchParams,AbortController,Date:clock.Date,setTimeout:clock.setTimeout,clearTimeout:clock.clearTimeout,
|
|
28
|
+
addEventListener(name,fn){listeners.set(name,fn);},removeEventListener(name,fn){if(listeners.get(name)===fn)listeners.delete(name);}};
|
|
29
|
+
new Function('window','document','globalThis','setTimeout','clearTimeout','Date',source)(window,document,scope,scope.setTimeout,scope.clearTimeout,clock.Date);
|
|
30
|
+
const module=loaded.factory(id=>{if(id!=='react')throw new Error('Unexpected client require: '+id);return React;});
|
|
31
|
+
function render(){cursor=0;tree=module.LibraryOverlay({embedded:true});for(const effect of pendingEffects.splice(0))effect();return tree;}
|
|
32
|
+
function find(node,predicate){if(!node)return;if(Array.isArray(node)){for(const child of node){const match=find(child,predicate);if(match)return match;}return;}if(typeof node!=='object')return;if(predicate(node))return node;return find(node.children,predicate);}
|
|
33
|
+
module.apply({effect:run=>{disposers.push(run());},slots:{inject:(_n,run)=>run(),register:()=>()=>{}},reflect:{get:()=>undefined},provide:()=>()=>{}});
|
|
34
|
+
render();await idle();
|
|
35
|
+
return{module,
|
|
36
|
+
text:()=>JSON.stringify(tree),
|
|
37
|
+
button:label=>find(tree,node=>node.type==='button'&&node.children.includes(label)),
|
|
38
|
+
async click(label){const button=find(tree,node=>node.type==='button'&&node.children.includes(label));if(!button)throw new Error('No visible button '+label);button.props.onClick();await idle();},
|
|
39
|
+
idle,
|
|
40
|
+
async tick(ms){clock.advance(ms);await idle();},
|
|
41
|
+
async navigate(hash){scope.location.hash=hash;listeners.get('hashchange')?.();await idle();},
|
|
42
|
+
unmount(){dead=true;for(const dispose of disposers)dispose?.();for(const hook of hooks)hook?.cleanup?.();}};
|
|
43
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Install-target contract suite runner.
|
|
4
|
+
* node <package>/dist/install-target/run.js provider
|
|
5
|
+
* node <package>/dist/install-target/run.js consumer [--module <file-or-specifier>] [--export <name>] [--unversioned]
|
|
6
|
+
* Without --module the bundled reference consumer fixture is used. The module export is a consumer object or a
|
|
7
|
+
* zero-argument factory returning one. Prints one JSON report per suite; exits 1 when any case fails.
|
|
8
|
+
*/
|
|
9
|
+
import { pathToFileURL } from 'node:url';
|
|
10
|
+
import { resolve } from 'node:path';
|
|
11
|
+
import { runProviderSuite, runConsumerSuite, runChainSuite } from './suite.js';
|
|
12
|
+
import { createReferenceConsumer } from './fixtures/reference-consumer.js';
|
|
13
|
+
|
|
14
|
+
const [mode,...rest]=process.argv.slice(2);
|
|
15
|
+
const option=name=>{const index=rest.indexOf(name);return index<0?undefined:rest[index+1];};
|
|
16
|
+
async function consumer(){
|
|
17
|
+
const specifier=option('--module');
|
|
18
|
+
if(!specifier)return createReferenceConsumer(rest.includes('--unversioned')?{contractVersion:undefined}:{});
|
|
19
|
+
const loaded=await import(/^[./]/u.test(specifier)?pathToFileURL(resolve(specifier)).href:specifier);
|
|
20
|
+
const value=loaded[option('--export')??'default'];
|
|
21
|
+
return typeof value==='function'?await value():value;
|
|
22
|
+
}
|
|
23
|
+
const reports=[];
|
|
24
|
+
if(mode==='provider')reports.push(await runProviderSuite());
|
|
25
|
+
else if(mode==='consumer'){const subject=await consumer();reports.push(await runConsumerSuite(subject),await runChainSuite(subject));}
|
|
26
|
+
else{process.stderr.write('usage: run.js provider | consumer [--module <file>] [--export <name>] [--unversioned]\n');process.exit(2);}
|
|
27
|
+
for(const report of reports)process.stdout.write(JSON.stringify(report)+'\n');
|
|
28
|
+
process.exitCode=reports.every(report=>report.fail===0)?0:1;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Install-target contract conformance suite (hanamesh.install-target v1). */
|
|
2
|
+
export interface ContractCaseResult {id:string;semantic:string;note:string;outcome:'pass'|'fail'|'skipped';detail?:string;}
|
|
3
|
+
export interface ContractSuiteReport {suite:'provider'|'consumer'|'chain';contract:'hanamesh.install-target';contractVersion:string;total:number;pass:number;fail:number;skipped:number;results:ContractCaseResult[];[key:string]:unknown;}
|
|
4
|
+
export interface InstallTargetFields {itemId?:string;packageName?:string;contractVersion?:string;}
|
|
5
|
+
export interface ProviderDeclaration {contract:string;contractVersion:string;supported:string[];schema?:string;}
|
|
6
|
+
/** A caller handing catalog identities to the market by normal navigation of its own workspace. */
|
|
7
|
+
export interface InstallTargetConsumer {
|
|
8
|
+
name?:string;
|
|
9
|
+
/** Omit for an unversioned (v1) caller; a declared version requires `handshake`. */
|
|
10
|
+
contractVersion?:string;
|
|
11
|
+
navigate(workspaceUrl:string,target:Record<string,unknown>):{ok:true;url:string}|{ok:false;code:string}|Promise<{ok:true;url:string}|{ok:false;code:string}>;
|
|
12
|
+
handshake?(declaration:ProviderDeclaration|null):{ok:boolean;code?:string}|Promise<{ok:boolean;code?:string}>;
|
|
13
|
+
}
|
|
14
|
+
export function loadContract():Promise<{schema:Record<string,unknown>;contract:Record<string,unknown>;declaration:ProviderDeclaration;packageVersion:string;provider:unknown;consumer:unknown;catalogPath:string}>;
|
|
15
|
+
/** Readable violations of the given schema definition (default: the target fields). */
|
|
16
|
+
export function validate(schema:Record<string,unknown>,value:unknown,ref?:string):string[];
|
|
17
|
+
export function runProviderSuite():Promise<ContractSuiteReport>;
|
|
18
|
+
export function runConsumerSuite(consumer:InstallTargetConsumer):Promise<ContractSuiteReport>;
|
|
19
|
+
export function runChainSuite(consumer:InstallTargetConsumer):Promise<ContractSuiteReport>;
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install-target contract conformance suite (hanamesh.install-target v1), shipped with the provider package.
|
|
3
|
+
* Provider cases drive this package's real HTTP routes, library service and client module against the bundled
|
|
4
|
+
* test catalog; consumer cases check what a caller hands to the market; the chain feeds consumer output into the
|
|
5
|
+
* real provider. Fixture data is a test catalog and is labelled as such in the market UI.
|
|
6
|
+
*/
|
|
7
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
8
|
+
import { join, dirname } from 'node:path';
|
|
9
|
+
import { createServer, request } from 'node:http';
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { createLibraryService, INSTALL_TARGET_CONTRACT_VERSION } from '../library/service.js';
|
|
13
|
+
import { createLibraryHttpHandler } from '../library/routes.js';
|
|
14
|
+
import { createLibraryInstaller } from '../library/install.js';
|
|
15
|
+
import { loadClient, settle } from './harness.js';
|
|
16
|
+
import { createSourceProfile, applicationVersion } from './fixtures/source-profile.js';
|
|
17
|
+
|
|
18
|
+
const json=async url=>JSON.parse(await readFile(url,'utf8'));
|
|
19
|
+
const SCHEMA_URL=new URL('../../schemas/install-target.schema.json',import.meta.url);
|
|
20
|
+
const FIXTURES=new URL('./fixtures/',import.meta.url);
|
|
21
|
+
const ROUTE='/hanamesh/library/target';
|
|
22
|
+
|
|
23
|
+
/** Schema, provider declaration and both fixture sets as shipped. */
|
|
24
|
+
export async function loadContract(){
|
|
25
|
+
const [schema,pkg,provider,consumer,recovery]=await Promise.all([json(SCHEMA_URL),json(new URL('../../package.json',import.meta.url)),json(new URL('provider-cases.json',FIXTURES)),json(new URL('consumer-cases.json',FIXTURES)),json(new URL('install-failure-cases.json',FIXTURES))]);
|
|
26
|
+
return{schema,contract:schema['x-hanamesh-contract'],declaration:pkg.hanamesh?.installTarget,packageVersion:pkg.version,provider,consumer,recovery,catalogPath:fileURLToPath(new URL(provider.catalog,FIXTURES))};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Validates the JSON Schema keywords the contract uses; returns readable violations. */
|
|
30
|
+
export function validate(schema,value,ref='#/$defs/target'){
|
|
31
|
+
const errors=[];
|
|
32
|
+
const resolve=pointer=>pointer.slice(2).split('/').reduce((node,key)=>node[key],schema);
|
|
33
|
+
const check=(node,value,path)=>{
|
|
34
|
+
if(node.$ref){check(resolve(node.$ref),value,path);}
|
|
35
|
+
const type=Array.isArray(value)?'array':value===null?'null':typeof value;
|
|
36
|
+
if(node.type&&!(Array.isArray(node.type)?node.type:[node.type]).includes(type)){errors.push(`${path}: expected ${node.type}`);return;}
|
|
37
|
+
if('const' in node&&value!==node.const)errors.push(`${path}: must equal ${JSON.stringify(node.const)}`);
|
|
38
|
+
if(node.enum&&!node.enum.includes(value))errors.push(`${path}: not one of ${JSON.stringify(node.enum)}`);
|
|
39
|
+
if(type==='string'){
|
|
40
|
+
if(node.minLength!==undefined&&value.length<node.minLength)errors.push(`${path}: shorter than ${node.minLength}`);
|
|
41
|
+
if(node.maxLength!==undefined&&value.length>node.maxLength)errors.push(`${path}: longer than ${node.maxLength}`);
|
|
42
|
+
if(node.pattern&&!new RegExp(node.pattern,'u').test(value))errors.push(`${path}: does not match the contract grammar`);
|
|
43
|
+
}
|
|
44
|
+
if(type==='object'){
|
|
45
|
+
for(const key of node.required??[])if(!(key in value))errors.push(`${path}: missing ${key}`);
|
|
46
|
+
for(const [key,child] of Object.entries(node.properties??{}))if(key in value)check(child,value[key],`${path}.${key}`);
|
|
47
|
+
if(node.additionalProperties===false)for(const key of Object.keys(value))if(!(key in (node.properties??{})))errors.push(`${path}: field ${key} is not part of the contract`);
|
|
48
|
+
}
|
|
49
|
+
if(type==='array'&&node.items)value.forEach((item,index)=>check(node.items,item,`${path}[${index}]`));
|
|
50
|
+
if(node.anyOf&&!node.anyOf.some(option=>{const before=errors.length;check(option,value,path);const ok=errors.length===before;errors.length=before;return ok;}))errors.push(`${path}: needs one of ${node.anyOf.map(o=>JSON.stringify(o.required??o)).join(' / ')}`);
|
|
51
|
+
};
|
|
52
|
+
check(resolve(ref),value,'target');return errors;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function recorder(suite){
|
|
56
|
+
const results=[];
|
|
57
|
+
const record=async(id,semantic,note,run)=>{try{const detail=await run();results.push({id,semantic,note,outcome:detail==='skipped'?'skipped':'pass',...(detail&&detail!=='skipped'?{detail}:{})});}
|
|
58
|
+
catch(error){results.push({id,semantic,note,outcome:'fail',detail:String(error.message??error)});}};
|
|
59
|
+
const summary=extra=>({suite,...extra,total:results.length,pass:results.filter(r=>r.outcome==='pass').length,fail:results.filter(r=>r.outcome==='fail').length,skipped:results.filter(r=>r.outcome==='skipped').length,results});
|
|
60
|
+
return{record,summary};
|
|
61
|
+
}
|
|
62
|
+
const expectEqual=(actual,expected,what)=>{if(JSON.stringify(actual)!==JSON.stringify(expected))throw new Error(`${what}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);};
|
|
63
|
+
|
|
64
|
+
/** Starts the real provider routes on a random loopback port with the bundled test catalog and a counting installer. */
|
|
65
|
+
export async function startProvider({catalogPath},{profile,installer:real}={}){
|
|
66
|
+
let state={schema:1,revision:0,sources:[]};const installs=[],pending=[];const token=randomUUID();let service;
|
|
67
|
+
// rc.10: recovery cases install through the real installer on a SOURCE profile; every other case keeps the counting installer.
|
|
68
|
+
let made;const installer=profile?{install:(item,context)=>{installs.push(item.package.name);const work=(made??=real(event=>service.emit(event))).install(item,context);pending.push(work.catch(()=>{}));return work;}}
|
|
69
|
+
:{install:async item=>{installs.push(item.package.name);return{kind:'plugin',status:'restart-required'};}};
|
|
70
|
+
service=await createLibraryService({domain:{global:{get:async()=>state,set:async next=>{state=next;}},close:async()=>{}},host:{list:()=>({apps:[]}),instanceList:()=>[]},
|
|
71
|
+
config:{fixture:catalogPath,...(profile?{profileDir:profile.profileDir}:{})},...(profile?{dataRoot:profile.dataRoot,ledgerReader:profile.ledger}:{}),installer}).init();
|
|
72
|
+
const server=createServer();await new Promise(r=>server.listen(0,'127.0.0.1',r));
|
|
73
|
+
const origin='http://127.0.0.1:'+server.address().port;
|
|
74
|
+
server.on('request',createLibraryHttpHandler(service,{parentOrigin:origin,
|
|
75
|
+
authenticate:async req=>req.headers['x-install-target-suite']===token?{principalId:'install-target-suite'}:null,
|
|
76
|
+
authorize:async(_subject,_path,input)=>input.packageName!=='@hanamesh-fixture/forbidden'}));
|
|
77
|
+
const origins=[origin,origin.replace('127.0.0.1','localhost')];
|
|
78
|
+
const posts=[];
|
|
79
|
+
/** Same-origin browser fetch as the workspace page would issue it. */
|
|
80
|
+
const browserFetchFor=workspaceOrigin=>async(path,init={})=>{const method=init.method??'GET';if(method!=='GET')posts.push({path,headers:{...init.headers}});
|
|
81
|
+
return await fetch(workspaceOrigin+path,{...init,headers:{...init.headers,'x-install-target-suite':token,...(method==='GET'?{}:{origin:workspaceOrigin})}});};
|
|
82
|
+
return{origin,origins,token,service,installs,posts,settled:async()=>{await Promise.all(pending);await settle();},browserFetch:browserFetchFor(origin),browserFetchFor,
|
|
83
|
+
get:(query,headers={})=>fetch(origin+ROUTE+(query?'?'+query:''),{headers:{'x-install-target-suite':token,...headers}}),
|
|
84
|
+
close:async()=>{await new Promise(r=>server.close(r));await service.close();}};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** rc.10: a SOURCE profile holding the recovery fixture registry, with the case's injected failures and the real installer factory. */
|
|
88
|
+
export async function sourceRecoveryProfile({recovery},item={}){
|
|
89
|
+
const {application:app,plugin}=recovery;
|
|
90
|
+
const registry={[app.packageName]:[{version:app.previous,app:applicationVersion({appId:app.appId,version:app.previous}).app},{version:app.latest,app:applicationVersion({appId:app.appId,version:app.latest,invalid:Boolean(item.invalidDefinition)}).app}],
|
|
91
|
+
[plugin.packageName]:[{version:plugin.latest,bundle:true}]};
|
|
92
|
+
const profile=await createSourceProfile({registry}),target=item.kind==='plugin'?plugin:app;
|
|
93
|
+
if(item.mode==='upgrade')await profile.seed(target.packageName,app.previous);
|
|
94
|
+
for(const [command,phase,skip] of item.inject??[])profile.fail(command,phase,skip);
|
|
95
|
+
if(item.concurrent)profile.concurrentAdd('@fixture/concurrent','1.0.0');
|
|
96
|
+
const installer=emit=>createLibraryInstaller({profileDir:profile.profileDir,profileName:profile.profileName,dataRoot:profile.dataRoot,nodeBinary:'/source-fixture/bin/node',dshBin:'/source-fixture/dsh/lib/bin.js',
|
|
97
|
+
fetchImpl:profile.fetchImpl(item.registryMissing?{}:{[app.packageName]:app.latest,[plugin.packageName]:plugin.latest}),spawn:profile.spawn,provision:profile.provision,remove:profile.remove,ledger:profile.ledger,emit});
|
|
98
|
+
return{profile,target,installer};
|
|
99
|
+
}
|
|
100
|
+
/** Independent readback of the SOURCE profile: exact package.json/lockfile text, resolved module version, runtime ledger without timestamps. */
|
|
101
|
+
async function profileState({profile},name,appId){
|
|
102
|
+
const read=path=>readFile(path,'utf8').catch(error=>error.code==='ENOENT'?null:Promise.reject(error));
|
|
103
|
+
const module=await read(join(profile.profileDir,'node_modules',...name.split('/'),'package.json'));
|
|
104
|
+
const ledger=Object.fromEntries(Object.entries((await profile.ledger(join(profile.dataRoot,'runtimes',appId))).items).map(([id,{installedAt:_,...entry}])=>[id,entry]));
|
|
105
|
+
return{manifest:await read(join(profile.profileDir,'package.json')),lock:await read(join(profile.profileDir,'pnpm-lock.yaml')),module:module===null?null:JSON.parse(module).version,ledger};
|
|
106
|
+
}
|
|
107
|
+
/** Runs one install-failure case through the real service and installer on a fresh SOURCE profile; returns the failure event. */
|
|
108
|
+
export async function runRecoveryCase(contract,item,{check=true}={}){
|
|
109
|
+
const {schema,recovery}=contract;const source=await sourceRecoveryProfile(contract,item);const {profile,target}=source;const appId=recovery.application.appId;
|
|
110
|
+
const host=await startProvider(contract,{profile,installer:source.installer});
|
|
111
|
+
try{
|
|
112
|
+
const sentinel=join(profile.dataRoot,'principal',appId,'local','data-v1','single','user.db');await mkdir(dirname(sentinel),{recursive:true});await writeFile(sentinel,'keep');
|
|
113
|
+
const before=await profileState(source,target.packageName,appId);
|
|
114
|
+
const started=await host.service.install({packageName:target.packageName});await host.settled();
|
|
115
|
+
const events=host.service.events().events.filter(event=>event.operationId===started.operationId),failed=events.find(event=>event.type==='library.install-failed');
|
|
116
|
+
if(!failed||events.some(event=>event.type==='library.install-done'))throw new Error('expected one failed operation, got '+JSON.stringify(events.map(event=>event.type)));
|
|
117
|
+
if(!check)return failed;
|
|
118
|
+
const errors=validate(schema,failed,'#/$defs/installFailed');if(errors.length)throw new Error(errors.join('; '));
|
|
119
|
+
const result=failed.recovery,fill=text=>text.replaceAll('$package',target.packageName).replaceAll('$previous',recovery.application.previous);
|
|
120
|
+
expectEqual([failed.code,result.status,result.mode,result.stage,result.previousVersion],[item.expect.code,item.expect.status,item.mode,item.stage,item.mode==='upgrade'?recovery.application.previous:null],'original error and recovery result');
|
|
121
|
+
expectEqual(result.actions,item.expect.actions.map(fill),'compensation actions');
|
|
122
|
+
expectEqual(result.error?.code??null,item.expect.recoveryError??null,'compensation error');
|
|
123
|
+
expectEqual([...new Set(result.residue.map(row=>row.item))].sort(),[...(item.expect.residue??[])].sort(),'residue');
|
|
124
|
+
expectEqual(profile.calls.filter(call=>call[0]==='add'&&call.at(-1)===`${target.packageName}@${target.latest}`).length,item.registryMissing?0:1,'add attempts (no automatic retry)');
|
|
125
|
+
expectEqual(await readFile(sentinel,'utf8'),'keep','application data under dataRoot');
|
|
126
|
+
const after=await profileState(source,target.packageName,appId);
|
|
127
|
+
if(result.status==='failed'){
|
|
128
|
+
const unowned=result.residue.some(row=>row.reason==='UNOWNED_FILES_KEPT');
|
|
129
|
+
if(JSON.stringify(after)===JSON.stringify(before)&&!item.concurrent&&!unowned)throw new Error('failed recovery reported for a profile equal to its baseline');
|
|
130
|
+
expectEqual(await readFile(join(result.originals.dir,'package.json'),'utf8'),before.manifest,'kept original package.json');
|
|
131
|
+
if(item.concurrent&&!JSON.parse(after.manifest).dependencies['@fixture/concurrent'])throw new Error('concurrent profile write was undone');
|
|
132
|
+
if(unowned)await readFile(join(profile.dataRoot,'runtimes',appId,'runtime','unowned.txt'));
|
|
133
|
+
}else expectEqual(after,before,'independent readback against the baseline');
|
|
134
|
+
return`SOURCE: ${result.status}; ${result.actions.length} compensation action(s); readback ${result.checks.filter(row=>row.ok).length}/${result.checks.length}`;
|
|
135
|
+
}finally{await host.close();await profile.cleanup();}
|
|
136
|
+
}
|
|
137
|
+
async function runRecoveryProvider(contract,record){
|
|
138
|
+
const {recovery,schema}=contract;
|
|
139
|
+
await record('RD01','recovery','schema declares the install-failed recovery result and the SOURCE fixture is labelled',async()=>{
|
|
140
|
+
expectEqual(contract.contract.installRecovery?.since,'0.2.0-rc.10','installRecovery.since');expectEqual(recovery.evidence,'SOURCE','fixture evidence label');
|
|
141
|
+
if(!schema.$defs.recovery||!schema.$defs.installFailed)throw new Error('recovery definitions missing');
|
|
142
|
+
for(const item of recovery.presentation)if(item.recovery){const errors=validate(schema,item.recovery,'#/$defs/recovery');if(errors.length)throw new Error(item.id+': '+errors.join('; '));}
|
|
143
|
+
});
|
|
144
|
+
for(const item of recovery.cases)await record(item.id,'recovery',`${item.mode} · ${item.stage}: ${item.note}`,()=>runRecoveryCase(contract,item));
|
|
145
|
+
for(const mode of ['new-install','upgrade'])await record(mode==='upgrade'?'RS02':'RS01','recovery',mode+' without failure: install-done only after the profile reads back the exact version',async()=>{
|
|
146
|
+
const source=await sourceRecoveryProfile(contract,{mode});const host=await startProvider(contract,{profile:source.profile,installer:source.installer});
|
|
147
|
+
try{const started=await host.service.install({packageName:source.target.packageName});await host.settled();
|
|
148
|
+
const done=host.service.events().events.find(event=>event.operationId===started.operationId&&event.type==='library.install-done');
|
|
149
|
+
if(!done)throw new Error('install did not complete');expectEqual(done.result.version,source.target.latest,'installed version');
|
|
150
|
+
const state=await profileState(source,source.target.packageName,recovery.application.appId);
|
|
151
|
+
expectEqual([JSON.parse(state.manifest).dependencies[source.target.packageName],state.module,state.ledger.runtime?.version],[source.target.latest,source.target.latest,source.target.latest],'readback');
|
|
152
|
+
}finally{await host.close();await source.profile.cleanup();}
|
|
153
|
+
});
|
|
154
|
+
for(const [id,caseId,expected,refused] of [['RU01','RB04','已恢复到安装前状态(已逐项读回)','恢复失败'],['RU02','RB11','恢复失败','已恢复到']])
|
|
155
|
+
await record(id,'recovery','market shows '+caseId+' as '+(id==='RU01'?'a failure that was restored':'a failure whose restoration failed, never as installed or rolled back'),async()=>{
|
|
156
|
+
const item=recovery.cases.find(row=>row.id===caseId),source=await sourceRecoveryProfile(contract,item);
|
|
157
|
+
const host=await startProvider(contract,{profile:source.profile,installer:source.installer});const client=await loadClient({fetch:host.browserFetch});
|
|
158
|
+
try{await client.module.openInstallTarget({packageName:source.target.packageName});await client.idle();await client.click('确认安装');await host.settled();await client.tick(1000);
|
|
159
|
+
const text=client.text();
|
|
160
|
+
if(!text.includes('安装失败:'+item.expect.code)||!text.includes(expected))throw new Error('market text: '+text.slice(text.indexOf('安装失败')>=0?text.indexOf('安装失败'):0).slice(0,300));
|
|
161
|
+
if(text.includes(refused)||text.includes('安装完成'))throw new Error('market misreports the recovery result');
|
|
162
|
+
}finally{client.unmount();await host.close();await source.profile.cleanup();}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Provider conformance: this package as the market that receives install targets. */
|
|
167
|
+
export async function runProviderSuite(){
|
|
168
|
+
const contract=await loadContract();const {schema,provider:cases}=contract;const {record,summary}=recorder('provider');
|
|
169
|
+
const host=await startProvider(contract);let client;
|
|
170
|
+
try{
|
|
171
|
+
await record('PD01','declaration','package, schema, service and client declare the same contract version',async()=>{
|
|
172
|
+
expectEqual(contract.declaration,{contract:contract.contract.name,contractVersion:contract.contract.version,supported:contract.contract.supported,schema:'./schemas/install-target.schema.json'},'package.json hanamesh.installTarget');
|
|
173
|
+
const declarationErrors=validate(schema,contract.declaration,'#/$defs/providerDeclaration');if(declarationErrors.length)throw new Error(declarationErrors.join('; '));
|
|
174
|
+
expectEqual(INSTALL_TARGET_CONTRACT_VERSION,contract.contract.version,'library service');
|
|
175
|
+
const probe=await loadClient({fetch:host.browserFetch});try{expectEqual(probe.module.INSTALL_TARGET_CONTRACT_VERSION,contract.contract.version,'client module');}finally{probe.unmount();}
|
|
176
|
+
expectEqual(cases.contractVersion,contract.contract.version,'provider fixture version');
|
|
177
|
+
});
|
|
178
|
+
for(const item of cases.targets)await record(item.id+'/http',item.semantic,item.note,async()=>{
|
|
179
|
+
const response=await host.get(new URLSearchParams(item.input).toString());const body=await response.json();
|
|
180
|
+
if(item.expect.error){expectEqual([response.status,body.error?.code],[item.expect.http,item.expect.error],'refusal');const errors=validate(schema,body,'#/$defs/error');if(errors.length)throw new Error(errors.join('; '));
|
|
181
|
+
if(item.expect.error==='CONTRACT_VERSION_UNSUPPORTED')expectEqual(body.error.details?.supported,contract.contract.supported,'advertised versions');return;}
|
|
182
|
+
expectEqual(response.status,200,'status');const errors=validate(schema,body,'#/$defs/resolution');if(errors.length)throw new Error(errors.join('; '));
|
|
183
|
+
expectEqual([body.item.id,body.item.package.name,body.contractVersion,body.source.kind],[item.expect.itemId,item.expect.packageName,contract.contract.version,'fixture'],'resolution');
|
|
184
|
+
});
|
|
185
|
+
for(const [id,note,headers,status,code] of [['PG01','foreign browser origin',{origin:'https://foreign.example'},403,'ORIGIN_DENIED'],['PG02','application frame',{'sec-fetch-dest':'iframe'},403,'FRAME_CONTROL_DENIED'],['PG03','missing host authentication',{'x-install-target-suite':'wrong'},401,'UNAUTHENTICATED']])
|
|
186
|
+
await record(id,'auth',note+' keeps the existing refusal',async()=>{const response=await host.get('packageName=%40hanamesh-fixture%2Finstall-target-plugin',headers);expectEqual([response.status,(await response.json()).error?.code],[status,code],'refusal');});
|
|
187
|
+
await record('PG04','auth','authorization refusal is kept',async()=>{const response=await host.get('packageName=%40hanamesh-fixture%2Fforbidden');expectEqual([response.status,(await response.json()).error?.code],[403,'FORBIDDEN'],'refusal');});
|
|
188
|
+
await record('PG05','auth','the resolver cannot be used to install',async()=>{const response=await fetch(host.origin+ROUTE,{method:'POST',headers:{'x-install-target-suite':host.token,origin:host.origin,'x-hanamesh-client':'workspace-v1','content-type':'application/json'},body:'{"packageName":"@hanamesh-fixture/install-target-plugin"}'});expectEqual(response.status,405,'status');});
|
|
189
|
+
await record('PG06','auth','install keeps same-origin and client-header checks, with or without a declaration',async()=>{
|
|
190
|
+
for(const body of [{packageName:'@hanamesh-fixture/install-target-plugin'},{packageName:'@hanamesh-fixture/install-target-plugin',contractVersion:'1'}]){
|
|
191
|
+
const response=await fetch(host.origin+'/hanamesh/library/install',{method:'POST',headers:{'x-install-target-suite':host.token,origin:host.origin,'content-type':'application/json'},body:JSON.stringify(body)});
|
|
192
|
+
expectEqual([response.status,(await response.json()).error?.code],[403,'CSRF_DENIED'],'install without client header');}
|
|
193
|
+
const unsupported=await fetch(host.origin+'/hanamesh/library/install',{method:'POST',headers:{'x-install-target-suite':host.token,origin:host.origin,'x-hanamesh-client':'workspace-v1','content-type':'application/json'},body:JSON.stringify({packageName:'@hanamesh-fixture/install-target-plugin',contractVersion:'2'})});
|
|
194
|
+
expectEqual([unsupported.status,(await unsupported.json()).error?.code],[400,'CONTRACT_VERSION_UNSUPPORTED'],'install with unsupported declaration');
|
|
195
|
+
});
|
|
196
|
+
await record('PD02','loopback','schema and fixture name exactly the two aliases at one configured instance/port',async()=>{
|
|
197
|
+
expectEqual(cases.loopback.aliases,contract.contract.loopback.aliases,'declared aliases');
|
|
198
|
+
expectEqual(host.origins.map(value=>new URL(value).hostname),cases.loopback.aliases,'provider aliases');
|
|
199
|
+
expectEqual(new Set(host.origins.map(value=>new URL(value).port)).size,1,'one provider port');
|
|
200
|
+
});
|
|
201
|
+
for(const workspaceOrigin of host.origins)for(const item of cases.loopback.cases)await record(item.id+'/'+new URL(workspaceOrigin).hostname,'loopback',item.note,async()=>{
|
|
202
|
+
const url=new URL(workspaceOrigin),otherOrigin=host.origins.find(value=>value!==workspaceOrigin);
|
|
203
|
+
const replacements={origin:workspaceOrigin,otherOrigin,port:url.port,otherPort:String(Number(url.port)===65535?65534:Number(url.port)+1),hostname:url.hostname};
|
|
204
|
+
const headers=Object.fromEntries(Object.entries(item.headers).map(([key,value])=>[key,value.replace(/\$(origin|otherOrigin|port|otherPort|hostname)\b/gu,(_,name)=>replacements[name])]));
|
|
205
|
+
const path=item.method==='GET'?ROUTE+'?'+new URLSearchParams(item.input):'/hanamesh/library/install';
|
|
206
|
+
const response=await new Promise((resolve,reject)=>{
|
|
207
|
+
const req=request(workspaceOrigin+path,{method:item.method,headers:{'x-install-target-suite':host.token,'content-type':'application/json',...headers}},res=>{const chunks=[];res.on('data',chunk=>chunks.push(chunk));res.on('end',()=>resolve({status:res.statusCode,json:async()=>JSON.parse(Buffer.concat(chunks).toString('utf8'))}));});
|
|
208
|
+
req.on('error',reject);req.end(item.method==='POST'?JSON.stringify(item.input):undefined);
|
|
209
|
+
});
|
|
210
|
+
const body=await response.json();expectEqual([response.status,body.error?.code??null],[item.expect.http,item.expect.error??null],'loopback boundary');
|
|
211
|
+
if(!item.expect.error)expectEqual([body.contractVersion,body.item.package.name],['1',item.input.packageName],'resolved target');
|
|
212
|
+
expectEqual([host.posts.length,host.installs.length],[0,0],'no installation from transport/refusal cases');
|
|
213
|
+
});
|
|
214
|
+
client=await loadClient({fetch:host.browserFetch});
|
|
215
|
+
for(const item of cases.targets)await record(item.id+'/client',item.semantic,item.note,async()=>{
|
|
216
|
+
let receipt;try{receipt=await client.module.openInstallTarget(item.input);}catch(error){if(!item.expect.error)throw error;expectEqual(String(error.message),item.expect.error,'client refusal');await client.idle();if(!client.text().includes(item.expect.error))throw new Error('refusal is not visible in the market');return;}
|
|
217
|
+
if(item.expect.error)throw new Error('client accepted '+JSON.stringify(receipt));
|
|
218
|
+
const errors=validate(schema,receipt,'#/$defs/receipt');if(errors.length)throw new Error(errors.join('; '));expectEqual(receipt,item.expect,'receipt');
|
|
219
|
+
});
|
|
220
|
+
for(const item of cases.fragments)await record(item.id+'/fragment',item.semantic,item.note,async()=>{
|
|
221
|
+
await client.navigate(item.fragment);const text=client.text();
|
|
222
|
+
if(item.expect.status==='confirmation-required'){if(!client.button('确认安装')||!client.button('取消'))throw new Error('no market confirmation');if(!text.includes('测试目录(fixture)'))throw new Error('fixture catalog is not labelled');return;}
|
|
223
|
+
if(client.button('确认安装'))throw new Error('refused fragment still offers confirmation');if(!text.includes(item.expect.error))throw new Error('refusal '+item.expect.error+' is not visible');
|
|
224
|
+
});
|
|
225
|
+
await record('PC01','cancel','cancelling a confirmation sends no install',async()=>{
|
|
226
|
+
await client.module.openInstallTarget({packageName:'@hanamesh-fixture/install-target-plugin'});await client.idle();await client.click('取消');
|
|
227
|
+
if(!client.text().includes('已取消,未安装'))throw new Error('cancel result not visible');expectEqual([host.posts.length,host.installs.length],[0,0],'install requests after cancel');
|
|
228
|
+
});
|
|
229
|
+
await record('PC02','confirm','only an explicit confirmation installs, once, through the existing guarded route',async()=>{
|
|
230
|
+
expectEqual(host.installs.length,0,'installs before confirmation');
|
|
231
|
+
await client.module.openInstallTarget({packageName:'@hanamesh-fixture/install-target-app',contractVersion:'1'});await client.idle();
|
|
232
|
+
const confirm=client.button('确认安装');confirm.props.onClick();confirm.props.onClick();await client.idle();
|
|
233
|
+
expectEqual(host.posts.map(p=>[p.path,p.headers['x-hanamesh-client']]),[['/hanamesh/library/install','workspace-v1']],'install requests');
|
|
234
|
+
expectEqual(host.installs,['@hanamesh-fixture/install-target-app'],'installer calls');
|
|
235
|
+
});
|
|
236
|
+
client.unmount();client=await loadClient({fetch:host.browserFetchFor(host.origins[1])});
|
|
237
|
+
await record('PC03','confirm','localhost fragment reaches confirmation, cancellation does not install, explicit confirmation installs once',async()=>{
|
|
238
|
+
const postsBefore=host.posts.length,installsBefore=host.installs.length;
|
|
239
|
+
await client.navigate('#hanamesh-install?packageName=%40hanamesh-fixture%2Finstall-target-plugin&contractVersion=1');
|
|
240
|
+
if(!client.button('确认安装'))throw new Error('localhost fragment did not reach confirmation');
|
|
241
|
+
await client.click('取消');expectEqual([host.posts.length,host.installs.length],[postsBefore,installsBefore],'localhost cancellation');
|
|
242
|
+
await client.module.openInstallTarget({packageName:'@hanamesh-fixture/install-target-plugin',contractVersion:'1'});await client.idle();
|
|
243
|
+
const confirm=client.button('确认安装');confirm.props.onClick();confirm.props.onClick();await client.idle();
|
|
244
|
+
expectEqual([host.posts.length,host.installs.length],[postsBefore+1,installsBefore+1],'localhost confirmed install once');
|
|
245
|
+
expectEqual(host.installs.at(-1),'@hanamesh-fixture/install-target-plugin','localhost installer target');
|
|
246
|
+
});
|
|
247
|
+
}finally{client?.unmount();await host.close();}
|
|
248
|
+
await runRecoveryProvider(contract,record);
|
|
249
|
+
return summary({contract:contract.contract.name,contractVersion:contract.contract.version,package:'@hanamesh/dsh-app-host@'+contract.packageVersion,loopback:{parentOrigin:host.origin,origins:host.origins,sameInstanceSamePort:true}});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Consumer conformance. `consumer` is `{name, contractVersion?, navigate(workspaceUrl,target), handshake?(declaration)}`:
|
|
254
|
+
* navigate returns `{ok:true,url}` (normal navigation of the same workspace) or `{ok:false,code}`; a consumer that declares
|
|
255
|
+
* a contractVersion must also implement handshake → `{ok:boolean,code?}`. Consumers without a declaration are v1 callers.
|
|
256
|
+
*/
|
|
257
|
+
export async function runConsumerSuite(consumer){
|
|
258
|
+
const contract=await loadContract();const {schema,consumer:cases}=contract;const {record,summary}=recorder('consumer');
|
|
259
|
+
const declared=consumer.contractVersion;
|
|
260
|
+
await record('CD01','declaration','consumer declaration is absent (v1) or a supported version with a handshake',async()=>{
|
|
261
|
+
if(declared===undefined)return'no declaration: unversioned v1 caller';
|
|
262
|
+
if(!contract.contract.supported.includes(declared))throw new Error('declares unsupported version '+JSON.stringify(declared));
|
|
263
|
+
if(typeof consumer.handshake!=='function')throw new Error('a versioned consumer must implement handshake');
|
|
264
|
+
});
|
|
265
|
+
for(const workspaceUrl of cases.workspaces??[cases.workspace])for(const item of cases.targets)await record(item.id+(new URL(workspaceUrl).hostname==='localhost'?'/localhost':''),item.expect==='navigate'?'public':'invalid',item.note,async()=>{
|
|
266
|
+
const result=await consumer.navigate(workspaceUrl,item.target);
|
|
267
|
+
if(item.expect==='refuse'){if(result?.ok!==false||result.url)throw new Error('consumer forwarded an invalid target: '+JSON.stringify(result));return;}
|
|
268
|
+
if(result?.ok!==true){if(item.optional)return'skipped';throw new Error('consumer refused a valid target: '+JSON.stringify(result));}
|
|
269
|
+
const workspace=new URL(workspaceUrl),url=new URL(result.url);
|
|
270
|
+
expectEqual([url.origin,url.pathname,url.search],[workspace.origin,workspace.pathname,workspace.search],'workspace origin/path/authentication');
|
|
271
|
+
const prefix=contract.contract.fragmentPrefix;if(!url.hash.startsWith(prefix))throw new Error('fragment does not start with '+prefix);
|
|
272
|
+
const params=new URLSearchParams(url.hash.slice(prefix.length)),keys=[...params.keys()];
|
|
273
|
+
if(new Set(keys).size!==keys.length)throw new Error('duplicate fragment fields');
|
|
274
|
+
const fields=Object.fromEntries(params),errors=validate(schema,fields);if(errors.length)throw new Error(errors.join('; '));
|
|
275
|
+
expectEqual(fields,{...item.target,...(declared===undefined?{}:{contractVersion:declared})},'fragment fields');
|
|
276
|
+
});
|
|
277
|
+
for(const item of cases.handshake)await record(item.id,'handshake',item.note,async()=>{
|
|
278
|
+
if(declared===undefined)return'skipped';
|
|
279
|
+
const result=await consumer.handshake(item.provider);
|
|
280
|
+
if(item.expect==='accept'){if(result?.ok!==true)throw new Error('refused a compatible provider: '+JSON.stringify(result));return;}
|
|
281
|
+
if(result?.ok!==false)throw new Error('accepted an incompatible provider declaration');
|
|
282
|
+
expectEqual(result.code,'CONTRACT_VERSION_UNSUPPORTED','refusal code');
|
|
283
|
+
});
|
|
284
|
+
for(const item of contract.recovery.presentation)await record(item.id,'recovery-presentation',item.note,async()=>{
|
|
285
|
+
if(typeof consumer.presentInstallFailure!=='function')return'skipped';
|
|
286
|
+
const shown=await consumer.presentInstallFailure({type:'library.install-failed',operationId:'fixture-operation',code:'FIXTURE_FAILURE',...(item.recovery?{recovery:item.recovery}:{})});
|
|
287
|
+
expectEqual([shown?.installed,shown?.restored],[item.expect.installed,item.expect.restored],'presentation');
|
|
288
|
+
if(item.expect.version)expectEqual(shown.version,item.expect.version,'restored version');
|
|
289
|
+
if(item.expect.residue)expectEqual(shown.residue,item.expect.residue,'residue shown');
|
|
290
|
+
});
|
|
291
|
+
return summary({contract:contract.contract.name,contractVersion:contract.contract.version,consumer:consumer.name??'unnamed',declared:declared??null});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Chain: consumer output → this package's real fragment entry, catalog resolution and confirmation; nothing installs. */
|
|
295
|
+
export async function runChainSuite(consumer){
|
|
296
|
+
const contract=await loadContract();const {consumer:cases}=contract;const {record,summary}=recorder('chain');
|
|
297
|
+
const host=await startProvider(contract);let client;
|
|
298
|
+
try{
|
|
299
|
+
for(const workspaceOrigin of host.origins){
|
|
300
|
+
client=await loadClient({fetch:host.browserFetchFor(workspaceOrigin)});
|
|
301
|
+
const workspaceUrl=workspaceOrigin+'/workspace/fixture-session?token=fixture-token#previous';
|
|
302
|
+
const aliasSuffix='/'+new URL(workspaceOrigin).hostname;
|
|
303
|
+
if(consumer.contractVersion!==undefined)await record('CX00'+aliasSuffix,'handshake','consumer accepts this provider package declaration',async()=>{const result=await consumer.handshake(contract.declaration);if(result?.ok!==true)throw new Error('consumer refused the shipped provider: '+JSON.stringify(result));});
|
|
304
|
+
for(const item of cases.targets.filter(c=>c.expect==='navigate'))await record('CX-'+item.id+aliasSuffix,'public',item.note+' → market confirmation',async()=>{
|
|
305
|
+
const result=await consumer.navigate(workspaceUrl,item.target);if(result?.ok!==true){if(item.optional)return'skipped';throw new Error('consumer refused');}
|
|
306
|
+
await client.navigate(new URL(result.url).hash);
|
|
307
|
+
if(!client.button('确认安装'))throw new Error('market did not reach confirmation: '+client.text().slice(0,400));
|
|
308
|
+
const resolved=await client.module.openInstallTarget(Object.fromEntries(new URLSearchParams(new URL(result.url).hash.slice(contract.contract.fragmentPrefix.length))));
|
|
309
|
+
expectEqual(resolved.itemId,item.resolves,'resolved catalog item');expectEqual([host.posts.length,host.installs.length],[0,0],'installs');
|
|
310
|
+
await client.click('取消');
|
|
311
|
+
});
|
|
312
|
+
await record('CX-MISMATCH'+(new URL(workspaceOrigin).hostname==='localhost'?'/localhost':''),'version-mismatch','the same navigation declaring an unsupported version is refused by the market',async()=>{
|
|
313
|
+
const item=cases.targets.find(c=>c.expect==='navigate'&&!c.optional);const result=await consumer.navigate(workspaceUrl,item.target);
|
|
314
|
+
const url=new URL(result.url),params=new URLSearchParams(url.hash.slice(contract.contract.fragmentPrefix.length));params.set('contractVersion','2');
|
|
315
|
+
await client.navigate(contract.contract.fragmentPrefix+params);
|
|
316
|
+
if(client.button('确认安装')||!client.text().includes('CONTRACT_VERSION_UNSUPPORTED'))throw new Error('unsupported declaration was not refused visibly');
|
|
317
|
+
expectEqual([host.posts.length,host.installs.length],[0,0],'installs');
|
|
318
|
+
});
|
|
319
|
+
client.unmount();
|
|
320
|
+
}
|
|
321
|
+
for(const caseId of ['RB04','RB10','RB11'])await record('CX-'+caseId,'recovery-presentation','real provider install-failed event for '+caseId+' → consumer presentation',async()=>{
|
|
322
|
+
if(typeof consumer.presentInstallFailure!=='function')return'skipped';
|
|
323
|
+
const item=contract.recovery.cases.find(row=>row.id===caseId),event=await runRecoveryCase(contract,item,{check:false});const shown=await consumer.presentInstallFailure(event);
|
|
324
|
+
expectEqual([shown?.installed,shown?.restored],[false,item.expect.status!=='failed'],'presentation of the real provider event');
|
|
325
|
+
});
|
|
326
|
+
}finally{client?.unmount();await host.close();}
|
|
327
|
+
return summary({contract:contract.contract.name,contractVersion:contract.contract.version,consumer:consumer.name??'unnamed',declared:consumer.contractVersion??null});
|
|
328
|
+
}
|