arcane-os 0.3.0 → 0.3.2
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/CHANGELOG.md +27 -0
- package/README.md +86 -117
- package/bin/arcane-test.mjs +170 -46
- package/browser-runtime/ai/browser-speech-artifacts.mjs +887 -909
- package/browser-runtime/ai/browser-speech-providers.mjs +96 -152
- package/browser-runtime/ai/browser-wasm-llm-provider.mjs +627 -819
- package/browser-runtime/ai/browser-wasm.mjs +24 -35
- package/browser-runtime/ai/browser-wllama-runtime.mjs +64 -316
- package/browser-runtime/ai/model-controller.mjs +584 -181
- package/browser-runtime/ai/speech-worker-client.mjs +8 -146
- package/browser-runtime/ai/speech-worker-runtime.mjs +643 -363
- package/browser-runtime/dom-event-instrumentation.mjs +55 -147
- package/browser-runtime/event-manager.mjs +239 -624
- package/package.json +5 -6
- package/runtime/arcane/components/app-bar.html +3 -15
- package/runtime/arcane/components/assistant-panel.html +10 -10
- package/runtime/arcane/components/calculator.html +1 -1
- package/runtime/arcane/components/chat.html +1359 -135
- package/runtime/arcane/components/conversation-view.html +2 -2
- package/runtime/arcane/components/document-inspector.html +11 -17
- package/runtime/arcane/components/file-manager.html +13 -56
- package/runtime/arcane/components/markdown-document.html +82 -281
- package/runtime/arcane/components/markdown-editor.html +7 -10
- package/runtime/arcane/components/media-embed.html +6 -6
- package/runtime/arcane/components/screen-capture.html +4 -4
- package/runtime/arcane/components/source-explanation.html +2 -2
- package/runtime/arcane/components/speech.html +112 -68
- package/runtime/arcane/components/terminal-workspace.html +4 -4
- package/runtime/arcane/components/theme-editor.html +1 -1
- package/runtime/arcane/components/unified-inbox.html +2 -2
- package/runtime/arcane/components/voice-transcription.html +31 -21
- package/runtime/arcane/entities/Calculation.js +2 -3
- package/runtime/arcane/entities/Chat.js +228 -43
- package/runtime/arcane/entities/Preference.js +3 -5
- package/runtime/arcane/entities/Weather.js +5 -5
- package/runtime/arcane/modules/AI.js +1050 -427
- package/runtime/arcane/modules/AIProviderRuntime.js +658 -363
- package/runtime/arcane/modules/AIResponseLength.js +9 -19
- package/runtime/arcane/modules/AIRuntimeState.js +109 -72
- package/runtime/arcane/modules/ArcaneNavigationPolicy.js +45 -32
- package/runtime/arcane/modules/BrowserTestSuite.js +78 -122
- package/runtime/arcane/modules/CalculatorEngine.js +9 -9
- package/runtime/arcane/modules/CommunicationAppController.js +3 -7
- package/runtime/arcane/modules/ComponentContracts.js +30 -32
- package/runtime/arcane/modules/ConfiguredAIChatSession.js +281 -230
- package/runtime/arcane/modules/ConversationActionItems.js +26 -59
- package/runtime/arcane/modules/ConversationClosingReport.js +34 -61
- package/runtime/arcane/modules/ConversationTimebox.js +27 -15
- package/runtime/arcane/modules/DBOPFSDocumentLibrary.js +152 -344
- package/runtime/arcane/modules/DocumentLexicalSearch.js +25 -91
- package/runtime/arcane/modules/HTMLImport.js +54 -1
- package/runtime/arcane/modules/IsolatedModelQuestionRunner.js +40 -203
- package/runtime/arcane/modules/LocalAIReadiness.js +40 -60
- package/runtime/arcane/modules/LocalAIReadinessController.js +15 -13
- package/runtime/arcane/modules/MD.js +1 -45
- package/runtime/arcane/modules/Mail.js +51 -103
- package/runtime/arcane/modules/MailOutbox.mjs +95 -193
- package/runtime/arcane/modules/MailTransport.mjs +36 -57
- package/runtime/arcane/modules/ModelDefinition.js +22 -106
- package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +39 -101
- package/runtime/arcane/modules/PersistentAIChatSession.js +281 -18
- package/runtime/arcane/modules/PreferenceStore.js +102 -30
- package/runtime/arcane/modules/RiskSignalAnalyzer.js +8 -9
- package/runtime/arcane/modules/ScopedOPFSCache.js +7 -42
- package/runtime/arcane/modules/ScreenCapture.js +175 -128
- package/runtime/arcane/modules/SpeechPlayback.js +46 -149
- package/runtime/arcane/modules/StaticDocumentCatalog.js +173 -407
- package/runtime/arcane/modules/ToolCallRouter.js +25 -12
- package/runtime/arcane/modules/YouTubeMedia.js +6 -5
- package/schemas/arcane-app-bundle.schema.json +13 -78
- package/schemas/arcane-app.schema.json +9 -25
- package/schemas/arcane-lock.schema.json +18 -151
- package/schemas/arcane-package.schema.json +2 -16
- package/schemas/native-build-plan.schema.json +119 -122
- package/src/app-descriptor.mjs +75 -132
- package/src/application-tests.mjs +200 -0
- package/src/cli/main.mjs +27 -46
- package/src/constants.mjs +3 -4
- package/src/dev-server.mjs +30 -324
- package/src/doctor.mjs +92 -154
- package/src/dom-event-instrumentation.mjs +55 -147
- package/src/errors.mjs +2 -3
- package/src/event-manager.mjs +239 -624
- package/src/event-queue.mjs +3 -3
- package/src/import-map.mjs +273 -1028
- package/src/index.mjs +14 -16
- package/src/installed-sdk-runtime.mjs +40 -62
- package/src/integrated-provider-loader.mjs +53 -382
- package/src/mail-api.mjs +0 -2
- package/src/mail-server.mjs +224 -580
- package/src/mail.mjs +4 -10
- package/src/native-plan.mjs +163 -598
- package/src/native-provider-loader.mjs +104 -1063
- package/src/packager/core.mjs +485 -3229
- package/src/process.mjs +5 -10
- package/src/release-bundle.mjs +292 -2405
- package/src/runtime.mjs +76 -396
- package/src/scaffold.mjs +30 -80
- package/src/sdk-browser-runtime.mjs +70 -626
- package/src/source-server.mjs +588 -0
- package/src/targets/index.mjs +78 -188
- package/src/templates/workspace-template.mjs +19 -135
- package/src/testing-loader.mjs +164 -0
- package/src/testing.mjs +1 -1
- package/src/toolchain.mjs +131 -544
- package/src/update-check.mjs +26 -64
- package/src/workspace-operation-lock.mjs +139 -430
- package/src/workspace-runtime.mjs +112 -779
- package/src/workspace.mjs +40 -302
- package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +0 -218
- package/browser-runtime/ai/ARCANE_AI_BROWSER_SPEECH_COMPONENTS.json +0 -203
- package/browser-runtime/ai/ARCANE_AI_BROWSER_WASM_COMPONENTS.json +0 -80
- package/browser-runtime/ai/internal/sha256.mjs +0 -166
- package/docs/architecture.md +0 -344
- package/docs/compatibility.md +0 -36
- package/docs/event-manager.md +0 -294
- package/docs/platform-targets.md +0 -108
- package/docs/publishing.md +0 -201
- package/docs/reference/README.md +0 -187
- package/docs/reference/ai/browser-speech-package-authority.json +0 -835
- package/docs/reference/ai/browser-speech.md +0 -1252
- package/docs/reference/ai/browser-wasm.md +0 -530
- package/docs/reference/arcane-ollama.md +0 -288
- package/docs/reference/availability-and-normalization.md +0 -183
- package/docs/reference/behavioral-testing.md +0 -133
- package/docs/reference/cli.md +0 -779
- package/docs/reference/core/README.md +0 -62
- package/docs/reference/core/arcane-ai-contracts.md +0 -907
- package/docs/reference/core/arcane-api.md +0 -601
- package/docs/reference/core/arcane-entities.md +0 -65
- package/docs/reference/core/arcane-events.md +0 -134
- package/docs/reference/core/ollama-module.md +0 -181
- package/docs/reference/core/reference/arcane-api/ai-and-ollama.md +0 -1909
- package/docs/reference/core/reference/arcane-api/applications-terminal-capabilities.md +0 -1057
- package/docs/reference/core/reference/arcane-api/core-and-events.md +0 -320
- package/docs/reference/core/reference/arcane-api/filesystem-storage-preferences-appearance.md +0 -610
- package/docs/reference/core/reference/arcane-api/namespaces.md +0 -1157
- package/docs/reference/core/reference/arcane-api/platform-installation-users-system.md +0 -1423
- package/docs/reference/core/reference/arcane-api/session-provisioning-diagnostics-development.md +0 -315
- package/docs/reference/event-manager.md +0 -1511
- package/docs/reference/inventory/package-api.json +0 -3284
- package/docs/reference/inventory/runtime-components.json +0 -1011
- package/docs/reference/inventory/runtime-entities.json +0 -26
- package/docs/reference/inventory/runtime-modules.json +0 -1431
- package/docs/reference/mail.md +0 -316
- package/docs/reference/protocols.md +0 -677
- package/docs/reference/runtime-components.md +0 -1366
- package/docs/reference/runtime-entities.md +0 -303
- package/docs/reference/runtime-modules.md +0 -2960
- package/docs/reference/sdk-api.md +0 -6694
- package/docs/roadmap.md +0 -79
- package/docs/work-amplification.md +0 -129
- package/runtime/ARCANE_RUNTIME_RELEASE.json +0 -826
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import * as ArcaneNetworkPolicy from './ArcaneNetworkPolicy.js?v=3';
|
|
2
2
|
|
|
3
|
-
function
|
|
4
|
-
|
|
5
|
-
if(!text)return fallback;
|
|
6
|
-
return text.replace(/[\u0000-\u001f\u007f]/g,' ').slice(0,maximum);
|
|
3
|
+
function completeText(value,fallback){
|
|
4
|
+
return typeof value==='string'&&value.length>0?value:fallback;
|
|
7
5
|
}
|
|
8
6
|
|
|
9
7
|
function decisionId(){
|
|
10
8
|
if(typeof globalThis.crypto?.randomUUID==='function')return globalThis.crypto.randomUUID();
|
|
11
|
-
return `navigation-${Date.now().toString(36)}-${Math.random().toString(36).
|
|
9
|
+
return `navigation-${Date.now().toString(36)}-${Math.random().toString(36).replace(/^0\./u,'')}`;
|
|
12
10
|
}
|
|
13
11
|
|
|
14
12
|
function destinationContext(value){
|
|
@@ -17,14 +15,14 @@ function destinationContext(value){
|
|
|
17
15
|
const defaultPort=url.protocol==='https:'?443:80;
|
|
18
16
|
const hostname=url.hostname;
|
|
19
17
|
const ipLiteral=isIpLiteralHostname(hostname);
|
|
20
|
-
return
|
|
18
|
+
return {
|
|
21
19
|
url:url.href,
|
|
22
20
|
hostname,
|
|
23
21
|
canonicalHostname:ipLiteral?hostname.replace(/^\[|\]$/g,''):ArcaneNetworkPolicy.canonicalNetworkHostname(hostname),
|
|
24
22
|
ipLiteral,
|
|
25
23
|
protocol:'tcp',
|
|
26
24
|
remotePort:url.port?Number(url.port):defaultPort
|
|
27
|
-
}
|
|
25
|
+
};
|
|
28
26
|
}
|
|
29
27
|
|
|
30
28
|
function isIpLiteralHostname(hostname){
|
|
@@ -37,31 +35,33 @@ function isIpLiteralHostname(hostname){
|
|
|
37
35
|
}
|
|
38
36
|
|
|
39
37
|
function blockedDecision(rule,target,policyGeneration,ruleType){
|
|
40
|
-
return
|
|
38
|
+
return {
|
|
41
39
|
blocked:true,
|
|
42
40
|
decisionId:decisionId(),
|
|
43
41
|
decidedAt:new Date().toISOString(),
|
|
44
42
|
policyGeneration,
|
|
45
|
-
|
|
43
|
+
secure:true,
|
|
44
|
+
ruleId:completeText(rule?.id,'unknown-rule'),
|
|
46
45
|
ruleType,
|
|
47
46
|
target:target.url,
|
|
48
|
-
matchedValue:ruleType==='domain'?
|
|
49
|
-
reason:
|
|
50
|
-
code:
|
|
51
|
-
title:
|
|
52
|
-
description:
|
|
53
|
-
}
|
|
54
|
-
source:
|
|
55
|
-
id:
|
|
56
|
-
label:
|
|
57
|
-
reference:
|
|
58
|
-
}
|
|
59
|
-
}
|
|
47
|
+
matchedValue:ruleType==='domain'?completeText(rule?.domain,target.hostname):completeText(rule?.cidr,target.hostname),
|
|
48
|
+
reason:{
|
|
49
|
+
code:completeText(rule?.reason?.code,'global-deny'),
|
|
50
|
+
title:completeText(rule?.reason?.title,'Blocked by the global deny policy'),
|
|
51
|
+
description:completeText(rule?.reason?.description,'Arcane stopped this navigation before sending a request.')
|
|
52
|
+
},
|
|
53
|
+
source:{
|
|
54
|
+
id:completeText(rule?.source?.id,'arcane-policy'),
|
|
55
|
+
label:completeText(rule?.source?.label,'Arcane global policy'),
|
|
56
|
+
reference:completeText(rule?.source?.reference,'')||null
|
|
57
|
+
}
|
|
58
|
+
};
|
|
60
59
|
}
|
|
61
60
|
|
|
62
61
|
function unavailableDecision(target,error){
|
|
63
|
-
return
|
|
62
|
+
return {
|
|
64
63
|
blocked:true,
|
|
64
|
+
secure:true,
|
|
65
65
|
decisionId:decisionId(),
|
|
66
66
|
decidedAt:new Date().toISOString(),
|
|
67
67
|
policyGeneration:null,
|
|
@@ -69,17 +69,17 @@ function unavailableDecision(target,error){
|
|
|
69
69
|
ruleType:'policy',
|
|
70
70
|
target:target.url,
|
|
71
71
|
matchedValue:target.hostname,
|
|
72
|
-
reason:
|
|
72
|
+
reason:{
|
|
73
73
|
code:'policy-unavailable',
|
|
74
74
|
title:'Navigation paused because policy is unavailable',
|
|
75
75
|
description:'Arcane could not verify the current global deny policy, so it did not send this request.'
|
|
76
|
-
}
|
|
77
|
-
source:
|
|
76
|
+
},
|
|
77
|
+
source:{
|
|
78
78
|
id:'arcane-runtime',
|
|
79
79
|
label:'Arcane policy safety boundary',
|
|
80
|
-
reference:
|
|
81
|
-
}
|
|
82
|
-
}
|
|
80
|
+
reference:completeText(error?.code||'','')||null
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
function requireNetworkMatcher(networkMatcher){
|
|
@@ -90,14 +90,26 @@ function requireNetworkMatcher(networkMatcher){
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
export function createArcaneNavigationGuard({
|
|
93
|
+
secure=false,
|
|
93
94
|
loadPolicy=ArcaneNetworkPolicy.loadArcaneNetworkPolicy,
|
|
94
95
|
onDecision=null,
|
|
95
96
|
networkMatcher=ArcaneNetworkPolicy.findDeniedNetworkRule
|
|
96
97
|
}={}){
|
|
98
|
+
if(typeof secure!=='boolean')throw new TypeError('secure must be a boolean.');
|
|
97
99
|
return async function guardArcaneNavigation(value,context={}){
|
|
98
100
|
const target=destinationContext(value);
|
|
99
101
|
let decision;
|
|
100
|
-
|
|
102
|
+
if(!secure){
|
|
103
|
+
decision={
|
|
104
|
+
blocked:false,
|
|
105
|
+
secure:false,
|
|
106
|
+
decisionId:decisionId(),
|
|
107
|
+
decidedAt:new Date().toISOString(),
|
|
108
|
+
policyGeneration:null,
|
|
109
|
+
target:target.url,
|
|
110
|
+
warning:'Optional Arcane navigation-policy hardening is not enabled.'
|
|
111
|
+
};
|
|
112
|
+
}else try{
|
|
101
113
|
if(!target.ipLiteral&&(!target.canonicalHostname||target.canonicalHostname!==target.hostname)){
|
|
102
114
|
const error=new TypeError('Arcane navigation policy requires a canonical hostname without a trailing root dot.');
|
|
103
115
|
error.code='ARCANE_NETWORK_POLICY_HOSTNAME_NONCANONICAL';
|
|
@@ -117,18 +129,19 @@ export function createArcaneNavigationGuard({
|
|
|
117
129
|
?blockedDecision(domainRule,target,policy.generation,'domain')
|
|
118
130
|
:networkRule
|
|
119
131
|
?blockedDecision(networkRule,target,policy.generation,'network')
|
|
120
|
-
:
|
|
132
|
+
:{
|
|
121
133
|
blocked:false,
|
|
134
|
+
secure:true,
|
|
122
135
|
decisionId:decisionId(),
|
|
123
136
|
decidedAt:new Date().toISOString(),
|
|
124
137
|
policyGeneration:policy.generation,
|
|
125
138
|
target:target.url
|
|
126
|
-
}
|
|
139
|
+
};
|
|
127
140
|
}catch(error){
|
|
128
141
|
decision=unavailableDecision(target,error);
|
|
129
142
|
}
|
|
130
143
|
if(typeof onDecision==='function'){
|
|
131
|
-
await onDecision(decision,
|
|
144
|
+
await onDecision(decision,{intent:completeText(context.intent,'embedded')});
|
|
132
145
|
}
|
|
133
146
|
return decision;
|
|
134
147
|
};
|
|
@@ -1,27 +1,21 @@
|
|
|
1
1
|
import {createArcaneEventSource} from 'arcane-os/event-manager';
|
|
2
2
|
|
|
3
|
-
const DEFAULT_MAX_TESTS=64;
|
|
4
|
-
const HARD_MAX_TESTS=256;
|
|
5
|
-
const DEFAULT_TIMEOUT_MS=5000;
|
|
6
|
-
const HARD_TIMEOUT_MS=60000;
|
|
7
|
-
const MAX_MESSAGE_CHARACTERS=1000;
|
|
8
|
-
const CONTROL_CHARACTERS=/[\u0000-\u001f\u007f]/;
|
|
9
|
-
const TEST_ID_PATTERN=/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
10
3
|
const RESULT_STATUSES=new Set(['fail','pass','skip']);
|
|
11
4
|
|
|
12
|
-
export const BROWSER_TEST_SUITE_EVENT_TYPES=
|
|
5
|
+
export const BROWSER_TEST_SUITE_EVENT_TYPES={
|
|
13
6
|
runCompleted:'browser-test-suite-complete',
|
|
14
7
|
runStarted:'browser-test-suite-start',
|
|
15
8
|
testCompleted:'browser-test-result',
|
|
16
9
|
testStarted:'browser-test-start'
|
|
17
|
-
}
|
|
10
|
+
};
|
|
18
11
|
|
|
19
|
-
export const BROWSER_TEST_SUITE_ERROR_CODES=
|
|
12
|
+
export const BROWSER_TEST_SUITE_ERROR_CODES={
|
|
20
13
|
assertion:'BROWSER_TEST_ASSERTION',
|
|
21
14
|
busy:'BROWSER_TEST_BUSY',
|
|
22
15
|
callbackRejectedLegacy:'BROWSER_TEST_ERROR',
|
|
23
16
|
clockInvalid:'BROWSER_TEST_INVALID_CLOCK',
|
|
24
17
|
descriptorCaseCollision:'BROWSER_TEST_CASE_COLLISION',
|
|
18
|
+
descriptorDuplicate:'BROWSER_TEST_CASE_COLLISION',
|
|
25
19
|
descriptorInvalid:'BROWSER_TEST_INVALID_DESCRIPTOR',
|
|
26
20
|
disposed:'BROWSER_TEST_SUITE_DISPOSED',
|
|
27
21
|
limitExceeded:'BROWSER_TEST_LIMIT',
|
|
@@ -31,9 +25,9 @@ export const BROWSER_TEST_SUITE_ERROR_CODES=Object.freeze({
|
|
|
31
25
|
runAborted:'BROWSER_TEST_ABORTED',
|
|
32
26
|
skipped:'BROWSER_TEST_SKIP',
|
|
33
27
|
timedOut:'BROWSER_TEST_TIMEOUT'
|
|
34
|
-
}
|
|
28
|
+
};
|
|
35
29
|
|
|
36
|
-
export const BROWSER_TEST_SUITE_REASONS=
|
|
30
|
+
export const BROWSER_TEST_SUITE_REASONS={
|
|
37
31
|
runAborted:'browser-test-run-aborted',
|
|
38
32
|
runCompleted:'browser-test-run-completed',
|
|
39
33
|
runFailed:'browser-test-run-failed',
|
|
@@ -46,7 +40,7 @@ export const BROWSER_TEST_SUITE_REASONS=Object.freeze({
|
|
|
46
40
|
testSkipped:'browser-test-skipped',
|
|
47
41
|
testStarted:'browser-test-started',
|
|
48
42
|
testTimedOut:'browser-test-timed-out'
|
|
49
|
-
}
|
|
43
|
+
};
|
|
50
44
|
|
|
51
45
|
function isPlainRecord(value){
|
|
52
46
|
return Boolean(value)
|
|
@@ -64,56 +58,34 @@ function fail(message,code,ErrorType=TypeError){
|
|
|
64
58
|
throw coded(new ErrorType(message),code);
|
|
65
59
|
}
|
|
66
60
|
|
|
67
|
-
function
|
|
68
|
-
const unknown=Object.keys(value).find(key=>!allowed.has(key));
|
|
69
|
-
if(unknown) fail(`${label} contains an unsupported field: ${unknown}.`,code);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function boundedInteger(value,label,{minimum,maximum}){
|
|
73
|
-
if(!Number.isSafeInteger(value)||value<minimum||value>maximum){
|
|
74
|
-
fail(`${label} must be an integer from ${minimum} through ${maximum}.`,'BROWSER_TEST_INVALID_LIMIT',RangeError);
|
|
75
|
-
}
|
|
76
|
-
return value;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function descriptorText(value,label,maximum){
|
|
61
|
+
function descriptorText(value,label){
|
|
80
62
|
if(typeof value!=='string') fail(`${label} must be a string.`,'BROWSER_TEST_INVALID_DESCRIPTOR');
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if(normalized.length>maximum) fail(`${label} exceeds ${maximum} characters.`,'BROWSER_TEST_INVALID_DESCRIPTOR',RangeError);
|
|
84
|
-
if(CONTROL_CHARACTERS.test(normalized)) fail(`${label} cannot contain control characters.`,'BROWSER_TEST_INVALID_DESCRIPTOR');
|
|
85
|
-
if(normalized!==normalized.normalize('NFC')) fail(`${label} must use Unicode NFC normalization.`,'BROWSER_TEST_INVALID_DESCRIPTOR');
|
|
86
|
-
return normalized;
|
|
63
|
+
if(!value.trim()) fail(`${label} cannot be empty.`,'BROWSER_TEST_INVALID_DESCRIPTOR');
|
|
64
|
+
return value;
|
|
87
65
|
}
|
|
88
66
|
|
|
89
67
|
function resultMessage(value,fallback){
|
|
90
68
|
if(value===undefined||value===null||value==='') return fallback;
|
|
91
|
-
const message=String(value)
|
|
92
|
-
|
|
93
|
-
.trim();
|
|
94
|
-
return (message||fallback).slice(0,MAX_MESSAGE_CHARACTERS);
|
|
69
|
+
const message=String(value);
|
|
70
|
+
return message.trim()?message:fallback;
|
|
95
71
|
}
|
|
96
72
|
|
|
97
|
-
function normalizeTests(value
|
|
73
|
+
function normalizeTests(value){
|
|
98
74
|
if(!Array.isArray(value)) fail('tests must be an array.','BROWSER_TEST_INVALID_OPTIONS');
|
|
99
|
-
if(value.length>maxTests) fail(`Test suite exceeds the ${maxTests}-test limit.`,'BROWSER_TEST_LIMIT',RangeError);
|
|
100
75
|
const seen=new Set();
|
|
101
|
-
return
|
|
76
|
+
return value.map((item,index)=>{
|
|
102
77
|
if(!isPlainRecord(item)) fail(`Test descriptor ${index+1} must be a plain object.`,'BROWSER_TEST_INVALID_DESCRIPTOR');
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const key=id.toLowerCase();
|
|
107
|
-
if(seen.has(key)) fail(`Test descriptors contain a case-colliding id: ${id}.`,'BROWSER_TEST_CASE_COLLISION');
|
|
108
|
-
seen.add(key);
|
|
78
|
+
const id=descriptorText(item.id,`Test descriptor ${index+1} id`);
|
|
79
|
+
if(seen.has(id)) fail(`Test descriptors contain a duplicate id: ${id}.`,'BROWSER_TEST_CASE_COLLISION');
|
|
80
|
+
seen.add(id);
|
|
109
81
|
if(typeof item.run!=='function') fail(`Test descriptor ${index+1} run must be a function.`,'BROWSER_TEST_INVALID_DESCRIPTOR');
|
|
110
|
-
return
|
|
82
|
+
return {
|
|
83
|
+
...item,
|
|
111
84
|
id,
|
|
112
|
-
name:descriptorText(item.name,`Test descriptor ${index+1} name
|
|
85
|
+
name:descriptorText(item.name,`Test descriptor ${index+1} name`),
|
|
113
86
|
run:item.run,
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}));
|
|
87
|
+
};
|
|
88
|
+
});
|
|
117
89
|
}
|
|
118
90
|
|
|
119
91
|
function defaultNow(){
|
|
@@ -122,22 +94,16 @@ function defaultNow(){
|
|
|
122
94
|
|
|
123
95
|
function normalizeOptions(input){
|
|
124
96
|
if(!isPlainRecord(input)) fail('Browser test suite options must be a plain object.','BROWSER_TEST_INVALID_OPTIONS');
|
|
125
|
-
knownKeys(input,new Set(['maxTests','now','tests','timeoutMs']),'Browser test suite options');
|
|
126
|
-
const maxTests=boundedInteger(input.maxTests??DEFAULT_MAX_TESTS,'maxTests',{minimum:1,maximum:HARD_MAX_TESTS});
|
|
127
|
-
const timeoutMs=boundedInteger(input.timeoutMs??DEFAULT_TIMEOUT_MS,'timeoutMs',{minimum:10,maximum:HARD_TIMEOUT_MS});
|
|
128
97
|
const now=input.now??defaultNow;
|
|
129
98
|
if(typeof now!=='function') fail('now must be a function.','BROWSER_TEST_INVALID_OPTIONS');
|
|
130
|
-
return
|
|
131
|
-
maxTests,
|
|
99
|
+
return {
|
|
132
100
|
now,
|
|
133
|
-
tests:normalizeTests(input.tests??[]
|
|
134
|
-
|
|
135
|
-
});
|
|
101
|
+
tests:normalizeTests(input.tests??[]),
|
|
102
|
+
};
|
|
136
103
|
}
|
|
137
104
|
|
|
138
105
|
function normalizeRunOptions(input){
|
|
139
106
|
if(!isPlainRecord(input)) fail('Test run options must be a plain object.','BROWSER_TEST_INVALID_OPTIONS');
|
|
140
|
-
knownKeys(input,new Set(['context','signal']),'Test run options');
|
|
141
107
|
const signal=input.signal??null;
|
|
142
108
|
if(signal!==null&&(
|
|
143
109
|
typeof signal!=='object'
|
|
@@ -145,13 +111,13 @@ function normalizeRunOptions(input){
|
|
|
145
111
|
||typeof signal.addEventListener!=='function'
|
|
146
112
|
||typeof signal.removeEventListener!=='function'
|
|
147
113
|
)) fail('signal must be an AbortSignal.','BROWSER_TEST_INVALID_OPTIONS');
|
|
148
|
-
return
|
|
114
|
+
return {context:input.context,signal};
|
|
149
115
|
}
|
|
150
116
|
|
|
151
117
|
function elapsed(now,start){
|
|
152
118
|
const end=Number(now());
|
|
153
119
|
if(!Number.isFinite(end)) fail('now() must return a finite number.','BROWSER_TEST_INVALID_CLOCK');
|
|
154
|
-
return Math.
|
|
120
|
+
return Math.max(0,end-start);
|
|
155
121
|
}
|
|
156
122
|
|
|
157
123
|
function startTime(now){
|
|
@@ -183,14 +149,7 @@ function abortError(cause){
|
|
|
183
149
|
return error;
|
|
184
150
|
}
|
|
185
151
|
|
|
186
|
-
function
|
|
187
|
-
const error=coded(new Error(`The check exceeded ${milliseconds} milliseconds.`),'BROWSER_TEST_TIMEOUT');
|
|
188
|
-
error.name='BrowserTestTimeoutError';
|
|
189
|
-
error.reason=BROWSER_TEST_SUITE_REASONS.testTimedOut;
|
|
190
|
-
return error;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function runWithTimeout(callback,{signal,timeoutMs}){
|
|
152
|
+
function runWithCancellation(callback,{signal}){
|
|
194
153
|
if(signal?.aborted) return Promise.reject(abortError(signal.reason));
|
|
195
154
|
const controller=new AbortController();
|
|
196
155
|
return new Promise((resolve,reject)=>{
|
|
@@ -198,7 +157,6 @@ function runWithTimeout(callback,{signal,timeoutMs}){
|
|
|
198
157
|
const finish=(handler,value)=>{
|
|
199
158
|
if(settled) return;
|
|
200
159
|
settled=true;
|
|
201
|
-
clearTimeout(timer);
|
|
202
160
|
signal?.removeEventListener('abort',onAbort);
|
|
203
161
|
handler(value);
|
|
204
162
|
};
|
|
@@ -206,10 +164,6 @@ function runWithTimeout(callback,{signal,timeoutMs}){
|
|
|
206
164
|
controller.abort(signal.reason);
|
|
207
165
|
finish(reject,abortError(signal.reason));
|
|
208
166
|
};
|
|
209
|
-
const timer=setTimeout(()=>{
|
|
210
|
-
controller.abort();
|
|
211
|
-
finish(reject,timeoutError(timeoutMs));
|
|
212
|
-
},timeoutMs);
|
|
213
167
|
signal?.addEventListener('abort',onAbort,{once:true});
|
|
214
168
|
Promise.resolve()
|
|
215
169
|
.then(()=>callback(controller.signal))
|
|
@@ -219,53 +173,56 @@ function runWithTimeout(callback,{signal,timeoutMs}){
|
|
|
219
173
|
|
|
220
174
|
function normalizedOutcome(value){
|
|
221
175
|
if(value===undefined||value===true){
|
|
222
|
-
return
|
|
176
|
+
return {status:'pass',message:'Passed.'};
|
|
223
177
|
}
|
|
224
178
|
if(value===false){
|
|
225
|
-
return
|
|
179
|
+
return {status:'fail',message:'The check returned false.',code:'BROWSER_TEST_ASSERTION'};
|
|
226
180
|
}
|
|
227
181
|
if(!isPlainRecord(value)){
|
|
228
|
-
return
|
|
182
|
+
return {status:'fail',message:'The check returned an invalid result.',code:'BROWSER_TEST_INVALID_RESULT'};
|
|
229
183
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
return Object.freeze({status:'fail',message:'The check returned an invalid result.',code:'BROWSER_TEST_INVALID_RESULT'});
|
|
184
|
+
if(!RESULT_STATUSES.has(value.status)){
|
|
185
|
+
return {status:'fail',message:'The check returned an invalid result.',code:'BROWSER_TEST_INVALID_RESULT'};
|
|
233
186
|
}
|
|
234
187
|
const fallback=value.status==='pass'?'Passed.':value.status==='skip'?'Skipped.':'Failed.';
|
|
235
|
-
return
|
|
188
|
+
return {...value,status:value.status,message:resultMessage(value.message,fallback)};
|
|
236
189
|
}
|
|
237
190
|
|
|
238
191
|
function outcomeFromError(error){
|
|
239
192
|
if(error?.code==='BROWSER_TEST_SKIP'){
|
|
240
|
-
return
|
|
193
|
+
return {status:'skip',message:resultMessage(error.message,'Skipped.'),code:error.code,error};
|
|
241
194
|
}
|
|
242
195
|
if(error?.code===BROWSER_TEST_SUITE_ERROR_CODES.runAborted){
|
|
243
|
-
return
|
|
196
|
+
return {status:'skip',message:resultMessage(error.message,'The run was aborted.'),code:error.code,error};
|
|
244
197
|
}
|
|
245
|
-
return
|
|
198
|
+
return {
|
|
246
199
|
status:'fail',
|
|
247
200
|
message:resultMessage(error?.message,'The check failed.'),
|
|
248
|
-
code:typeof error?.code==='string'?error.code
|
|
249
|
-
errorName:resultMessage(error?.name,'Error')
|
|
250
|
-
|
|
201
|
+
code:typeof error?.code==='string'?error.code:'BROWSER_TEST_ERROR',
|
|
202
|
+
errorName:resultMessage(error?.name,'Error'),
|
|
203
|
+
error
|
|
204
|
+
};
|
|
251
205
|
}
|
|
252
206
|
|
|
253
207
|
function resultRecord(test,outcome,durationMs){
|
|
254
|
-
return
|
|
208
|
+
return {
|
|
209
|
+
...outcome,
|
|
255
210
|
id:test.id,
|
|
256
211
|
name:test.name,
|
|
257
|
-
|
|
258
|
-
message:outcome.message,
|
|
259
|
-
...(outcome.code?{code:outcome.code}:{}),
|
|
260
|
-
...(outcome.errorName?{errorName:outcome.errorName}:{}),
|
|
212
|
+
outcome,
|
|
261
213
|
durationMs,
|
|
262
|
-
}
|
|
214
|
+
};
|
|
263
215
|
}
|
|
264
216
|
|
|
265
217
|
function skippedResult(test,message,code='BROWSER_TEST_ABORTED'){
|
|
266
218
|
return resultRecord(test,{status:'skip',message,code},0);
|
|
267
219
|
}
|
|
268
220
|
|
|
221
|
+
function publicTestDescriptor(test){
|
|
222
|
+
const {run,...detail}=test;
|
|
223
|
+
return {...detail};
|
|
224
|
+
}
|
|
225
|
+
|
|
269
226
|
function browserTestResultReason(result){
|
|
270
227
|
if(result.code===BROWSER_TEST_SUITE_ERROR_CODES.runAborted){
|
|
271
228
|
return BROWSER_TEST_SUITE_REASONS.runAborted;
|
|
@@ -290,28 +247,31 @@ function browserTestPublicDetail(type,detail){
|
|
|
290
247
|
?detail.totals.total
|
|
291
248
|
:null;
|
|
292
249
|
if(type===BROWSER_TEST_SUITE_EVENT_TYPES.runStarted){
|
|
293
|
-
return
|
|
250
|
+
return {
|
|
251
|
+
...detail,
|
|
294
252
|
reason:BROWSER_TEST_SUITE_REASONS.runStarted,
|
|
295
253
|
testCount
|
|
296
|
-
}
|
|
254
|
+
};
|
|
297
255
|
}
|
|
298
256
|
if(type===BROWSER_TEST_SUITE_EVENT_TYPES.testStarted){
|
|
299
|
-
return
|
|
257
|
+
return {
|
|
258
|
+
...detail,
|
|
300
259
|
reason:BROWSER_TEST_SUITE_REASONS.testStarted,
|
|
301
260
|
testId:test.id,
|
|
302
261
|
testIndex:detail.index,
|
|
303
262
|
testCount
|
|
304
|
-
}
|
|
263
|
+
};
|
|
305
264
|
}
|
|
306
265
|
if(type===BROWSER_TEST_SUITE_EVENT_TYPES.testCompleted){
|
|
307
|
-
return
|
|
266
|
+
return {
|
|
267
|
+
...detail,
|
|
308
268
|
reason:browserTestResultReason(result),
|
|
309
269
|
testId:result.id,
|
|
310
270
|
testIndex:detail.index,
|
|
311
271
|
testCount,
|
|
312
272
|
status:result.status,
|
|
313
273
|
...(typeof result.code==='string'?{code:result.code}:{})
|
|
314
|
-
}
|
|
274
|
+
};
|
|
315
275
|
}
|
|
316
276
|
const reason=detail.status==='aborted'
|
|
317
277
|
?BROWSER_TEST_SUITE_REASONS.runAborted
|
|
@@ -320,14 +280,15 @@ function browserTestPublicDetail(type,detail){
|
|
|
320
280
|
:detail.status==='skip'
|
|
321
281
|
?BROWSER_TEST_SUITE_REASONS.runWithoutPassesCompleted
|
|
322
282
|
:BROWSER_TEST_SUITE_REASONS.runCompleted;
|
|
323
|
-
return
|
|
283
|
+
return {
|
|
284
|
+
...detail,
|
|
324
285
|
reason,
|
|
325
286
|
status:detail.status,
|
|
326
287
|
testCount,
|
|
327
288
|
passedTestCount:detail.totals.pass,
|
|
328
289
|
failedTestCount:detail.totals.fail,
|
|
329
290
|
skippedTestCount:detail.totals.skip
|
|
330
|
-
}
|
|
291
|
+
};
|
|
331
292
|
}
|
|
332
293
|
|
|
333
294
|
function disposedError(){
|
|
@@ -344,9 +305,9 @@ function disposedError(){
|
|
|
344
305
|
*
|
|
345
306
|
* Test callbacks are trusted executable code supplied by the parent. This
|
|
346
307
|
* module never accepts source text, evaluates code, persists results, or
|
|
347
|
-
* selects application policy.
|
|
348
|
-
*
|
|
349
|
-
*
|
|
308
|
+
* selects application policy. Caller abort signals cancel cooperative
|
|
309
|
+
* asynchronous orchestration; callbacks must still stop work they started
|
|
310
|
+
* after their abort signal fires.
|
|
350
311
|
*/
|
|
351
312
|
export default class BrowserTestSuite extends EventTarget{
|
|
352
313
|
#activeRun=null;
|
|
@@ -364,7 +325,7 @@ export default class BrowserTestSuite extends EventTarget{
|
|
|
364
325
|
this.#tests=normalized.tests;
|
|
365
326
|
this.#events=createArcaneEventSource(this,{
|
|
366
327
|
source:'browser-test-suite',
|
|
367
|
-
eventTypes:Object.
|
|
328
|
+
eventTypes:Object.values(BROWSER_TEST_SUITE_EVENT_TYPES)
|
|
368
329
|
});
|
|
369
330
|
}
|
|
370
331
|
|
|
@@ -376,19 +337,14 @@ export default class BrowserTestSuite extends EventTarget{
|
|
|
376
337
|
get running(){return this.#running;}
|
|
377
338
|
|
|
378
339
|
list(){
|
|
379
|
-
return
|
|
380
|
-
id:test.id,
|
|
381
|
-
name:test.name,
|
|
382
|
-
timeoutMs:test.timeoutMs,
|
|
383
|
-
})));
|
|
340
|
+
return this.#tests.map(publicTestDescriptor);
|
|
384
341
|
}
|
|
385
342
|
|
|
386
343
|
#emit(type,detail,operationId){
|
|
387
344
|
if(this.#disposed)return false;
|
|
388
|
-
|
|
389
|
-
return this.#events.dispatch(type,compatibilityDetail,{
|
|
345
|
+
return this.#events.dispatch(type,detail,{
|
|
390
346
|
operationId,
|
|
391
|
-
publicDetail:browserTestPublicDetail(type,
|
|
347
|
+
publicDetail:browserTestPublicDetail(type,detail)
|
|
392
348
|
});
|
|
393
349
|
}
|
|
394
350
|
|
|
@@ -410,7 +366,7 @@ export default class BrowserTestSuite extends EventTarget{
|
|
|
410
366
|
};
|
|
411
367
|
if(settings.signal.aborted)abortBrowserTestRun();
|
|
412
368
|
}
|
|
413
|
-
const runRecord=
|
|
369
|
+
const runRecord={controller,operationId};
|
|
414
370
|
this.#activeRun=runRecord;
|
|
415
371
|
const results=[];
|
|
416
372
|
let aborted=controller.signal.aborted;
|
|
@@ -439,7 +395,7 @@ export default class BrowserTestSuite extends EventTarget{
|
|
|
439
395
|
BROWSER_TEST_SUITE_EVENT_TYPES.testStarted,
|
|
440
396
|
{
|
|
441
397
|
index,
|
|
442
|
-
test:
|
|
398
|
+
test:publicTestDescriptor(descriptor),
|
|
443
399
|
total:this.#tests.length,
|
|
444
400
|
},
|
|
445
401
|
testOperationId
|
|
@@ -447,12 +403,12 @@ export default class BrowserTestSuite extends EventTarget{
|
|
|
447
403
|
const testStart=startTime(this.#now);
|
|
448
404
|
let outcome;
|
|
449
405
|
try{
|
|
450
|
-
const value=await
|
|
406
|
+
const value=await runWithCancellation(signal=>descriptor.run({
|
|
451
407
|
assert(condition,message){if(!condition) throw assertionError(message);},
|
|
452
408
|
context:settings.context,
|
|
453
409
|
signal,
|
|
454
410
|
skip(message){throw skipError(message);},
|
|
455
|
-
})
|
|
411
|
+
}),{signal:controller.signal});
|
|
456
412
|
outcome=normalizedOutcome(value);
|
|
457
413
|
}catch(error){
|
|
458
414
|
outcome=outcomeFromError(error);
|
|
@@ -471,19 +427,19 @@ export default class BrowserTestSuite extends EventTarget{
|
|
|
471
427
|
removeAbortListener?.();
|
|
472
428
|
if(this.#activeRun===runRecord)this.#activeRun=null;
|
|
473
429
|
}
|
|
474
|
-
const totals=
|
|
430
|
+
const totals={
|
|
475
431
|
total:results.length,
|
|
476
432
|
pass:results.filter(result=>result.status==='pass').length,
|
|
477
433
|
fail:results.filter(result=>result.status==='fail').length,
|
|
478
434
|
skip:results.filter(result=>result.status==='skip').length,
|
|
479
|
-
}
|
|
435
|
+
};
|
|
480
436
|
const status=aborted?'aborted':totals.fail?'fail':totals.pass?'pass':'skip';
|
|
481
|
-
const summary=
|
|
437
|
+
const summary={
|
|
482
438
|
status,
|
|
483
439
|
totals,
|
|
484
440
|
durationMs:elapsed(this.#now,suiteStart),
|
|
485
|
-
results
|
|
486
|
-
}
|
|
441
|
+
results,
|
|
442
|
+
};
|
|
487
443
|
this.#emit(BROWSER_TEST_SUITE_EVENT_TYPES.runCompleted,summary,operationId);
|
|
488
444
|
return summary;
|
|
489
445
|
}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import {createArcaneEventSource} from 'arcane-os/event-manager';
|
|
2
2
|
import Calculation from '../entities/Calculation.js';
|
|
3
3
|
|
|
4
|
-
const FUNCTIONS=
|
|
5
|
-
const CONSTANTS=
|
|
6
|
-
export const CALCULATOR_ENGINE_ERROR_CODES=
|
|
4
|
+
const FUNCTIONS={sqrt:Math.sqrt,abs:Math.abs,sin:Math.sin,cos:Math.cos,tan:Math.tan,log:Math.log10,ln:Math.log};
|
|
5
|
+
const CONSTANTS={pi:Math.PI,e:Math.E};
|
|
6
|
+
export const CALCULATOR_ENGINE_ERROR_CODES={
|
|
7
7
|
disposed:'ARCANE_CALCULATOR_ENGINE_DISPOSED',
|
|
8
8
|
domain:'ARCANE_CALCULATOR_EXPRESSION_DOMAIN_INVALID',
|
|
9
9
|
evaluation:'ARCANE_CALCULATOR_EXPRESSION_EVALUATION_FAILED',
|
|
10
10
|
input:'ARCANE_CALCULATOR_EXPRESSION_INPUT_INVALID',
|
|
11
11
|
syntax:'ARCANE_CALCULATOR_EXPRESSION_SYNTAX_INVALID'
|
|
12
|
-
}
|
|
12
|
+
};
|
|
13
13
|
|
|
14
14
|
function calculatorErrorCode(error){
|
|
15
15
|
if(error instanceof TypeError)return CALCULATOR_ENGINE_ERROR_CODES.input;
|
|
@@ -34,7 +34,7 @@ function disposedError(){
|
|
|
34
34
|
);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function tokenize(input){const source=String(input??'').trim();if(!source
|
|
37
|
+
function tokenize(input){const source=String(input??'').trim();if(!source)throw new TypeError('Expression must not be empty.');const tokens=[];let index=0;while(index<source.length){const rest=source.slice(index);const whitespace=rest.match(/^\s+/);if(whitespace){index+=whitespace[0].length;continue}const numeric=rest.match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?/i);if(numeric){tokens.push({type:'number',value:Number(numeric[0])});index+=numeric[0].length;continue}const identifier=rest.match(/^[a-z]+/i);if(identifier){tokens.push({type:'name',value:identifier[0].toLowerCase()});index+=identifier[0].length;continue}const symbol=source[index];if('+-*/%^()'.includes(symbol)){tokens.push({type:symbol,value:symbol});index++;continue}throw new SyntaxError(`Unexpected character at position ${index+1}.`)}tokens.push({type:'end'});return tokens;}
|
|
38
38
|
|
|
39
39
|
export function evaluateExpression(input){const tokens=tokenize(input);let cursor=0;const peek=()=>tokens[cursor];const take=type=>{if(peek().type!==type)throw new SyntaxError(`Expected ${type}.`);return tokens[cursor++]};
|
|
40
40
|
function primary(){const token=peek();if(token.type==='number'){cursor++;return token.value}if(token.type==='name'){cursor++;if(Object.hasOwn(CONSTANTS,token.value))return CONSTANTS[token.value];const fn=FUNCTIONS[token.value];if(!fn)throw new SyntaxError(`Unknown function: ${token.value}.`);take('(');const value=expression();take(')');return fn(value)}if(token.type==='('){cursor++;const value=expression();take(')');return value}throw new SyntaxError('Expected a number, constant, function, or parenthesized expression.')}
|
|
@@ -49,7 +49,7 @@ export default class CalculatorEngine{
|
|
|
49
49
|
#disposed=false;
|
|
50
50
|
#events;
|
|
51
51
|
#operationSequence=0;
|
|
52
|
-
constructor(){this.#events=createArcaneEventSource(this,{source:'calculator-engine',eventTypes:
|
|
52
|
+
constructor(){this.#events=createArcaneEventSource(this,{source:'calculator-engine',eventTypes:['calculator-result','calculator-error']});}
|
|
53
53
|
addEventListener(type,listener,options){return this.#events.addEventListener(type,listener,options);}
|
|
54
54
|
removeEventListener(type,listener,options){return this.#events.removeEventListener(type,listener,options);}
|
|
55
55
|
on(type,listener,options){return this.#events.on(type,listener,options);}
|
|
@@ -61,7 +61,7 @@ export default class CalculatorEngine{
|
|
|
61
61
|
const calculation=new Calculation({expression,result:evaluateExpression(expression)});
|
|
62
62
|
this.#events.dispatch('calculator-result',calculation,{
|
|
63
63
|
operationId,
|
|
64
|
-
publicDetail:
|
|
64
|
+
publicDetail:{result:calculation.result}
|
|
65
65
|
});
|
|
66
66
|
return calculation;
|
|
67
67
|
}catch(error){
|
|
@@ -69,8 +69,8 @@ export default class CalculatorEngine{
|
|
|
69
69
|
attachCalculatorErrorCode(error,code);
|
|
70
70
|
this.#events.dispatch(
|
|
71
71
|
'calculator-error',
|
|
72
|
-
|
|
73
|
-
{operationId,publicDetail:
|
|
72
|
+
{expression:compatibilityExpression(expression),error},
|
|
73
|
+
{operationId,publicDetail:{code,error,expression:compatibilityExpression(expression)}}
|
|
74
74
|
);
|
|
75
75
|
throw error;
|
|
76
76
|
}
|