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.
Files changed (153) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +86 -117
  3. package/bin/arcane-test.mjs +170 -46
  4. package/browser-runtime/ai/browser-speech-artifacts.mjs +887 -909
  5. package/browser-runtime/ai/browser-speech-providers.mjs +96 -152
  6. package/browser-runtime/ai/browser-wasm-llm-provider.mjs +627 -819
  7. package/browser-runtime/ai/browser-wasm.mjs +24 -35
  8. package/browser-runtime/ai/browser-wllama-runtime.mjs +64 -316
  9. package/browser-runtime/ai/model-controller.mjs +584 -181
  10. package/browser-runtime/ai/speech-worker-client.mjs +8 -146
  11. package/browser-runtime/ai/speech-worker-runtime.mjs +643 -363
  12. package/browser-runtime/dom-event-instrumentation.mjs +55 -147
  13. package/browser-runtime/event-manager.mjs +239 -624
  14. package/package.json +5 -6
  15. package/runtime/arcane/components/app-bar.html +3 -15
  16. package/runtime/arcane/components/assistant-panel.html +10 -10
  17. package/runtime/arcane/components/calculator.html +1 -1
  18. package/runtime/arcane/components/chat.html +1359 -135
  19. package/runtime/arcane/components/conversation-view.html +2 -2
  20. package/runtime/arcane/components/document-inspector.html +11 -17
  21. package/runtime/arcane/components/file-manager.html +13 -56
  22. package/runtime/arcane/components/markdown-document.html +82 -281
  23. package/runtime/arcane/components/markdown-editor.html +7 -10
  24. package/runtime/arcane/components/media-embed.html +6 -6
  25. package/runtime/arcane/components/screen-capture.html +4 -4
  26. package/runtime/arcane/components/source-explanation.html +2 -2
  27. package/runtime/arcane/components/speech.html +112 -68
  28. package/runtime/arcane/components/terminal-workspace.html +4 -4
  29. package/runtime/arcane/components/theme-editor.html +1 -1
  30. package/runtime/arcane/components/unified-inbox.html +2 -2
  31. package/runtime/arcane/components/voice-transcription.html +31 -21
  32. package/runtime/arcane/entities/Calculation.js +2 -3
  33. package/runtime/arcane/entities/Chat.js +228 -43
  34. package/runtime/arcane/entities/Preference.js +3 -5
  35. package/runtime/arcane/entities/Weather.js +5 -5
  36. package/runtime/arcane/modules/AI.js +1050 -427
  37. package/runtime/arcane/modules/AIProviderRuntime.js +658 -363
  38. package/runtime/arcane/modules/AIResponseLength.js +9 -19
  39. package/runtime/arcane/modules/AIRuntimeState.js +109 -72
  40. package/runtime/arcane/modules/ArcaneNavigationPolicy.js +45 -32
  41. package/runtime/arcane/modules/BrowserTestSuite.js +78 -122
  42. package/runtime/arcane/modules/CalculatorEngine.js +9 -9
  43. package/runtime/arcane/modules/CommunicationAppController.js +3 -7
  44. package/runtime/arcane/modules/ComponentContracts.js +30 -32
  45. package/runtime/arcane/modules/ConfiguredAIChatSession.js +281 -230
  46. package/runtime/arcane/modules/ConversationActionItems.js +26 -59
  47. package/runtime/arcane/modules/ConversationClosingReport.js +34 -61
  48. package/runtime/arcane/modules/ConversationTimebox.js +27 -15
  49. package/runtime/arcane/modules/DBOPFSDocumentLibrary.js +152 -344
  50. package/runtime/arcane/modules/DocumentLexicalSearch.js +25 -91
  51. package/runtime/arcane/modules/HTMLImport.js +54 -1
  52. package/runtime/arcane/modules/IsolatedModelQuestionRunner.js +40 -203
  53. package/runtime/arcane/modules/LocalAIReadiness.js +40 -60
  54. package/runtime/arcane/modules/LocalAIReadinessController.js +15 -13
  55. package/runtime/arcane/modules/MD.js +1 -45
  56. package/runtime/arcane/modules/Mail.js +51 -103
  57. package/runtime/arcane/modules/MailOutbox.mjs +95 -193
  58. package/runtime/arcane/modules/MailTransport.mjs +36 -57
  59. package/runtime/arcane/modules/ModelDefinition.js +22 -106
  60. package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +39 -101
  61. package/runtime/arcane/modules/PersistentAIChatSession.js +281 -18
  62. package/runtime/arcane/modules/PreferenceStore.js +102 -30
  63. package/runtime/arcane/modules/RiskSignalAnalyzer.js +8 -9
  64. package/runtime/arcane/modules/ScopedOPFSCache.js +7 -42
  65. package/runtime/arcane/modules/ScreenCapture.js +175 -128
  66. package/runtime/arcane/modules/SpeechPlayback.js +46 -149
  67. package/runtime/arcane/modules/StaticDocumentCatalog.js +173 -407
  68. package/runtime/arcane/modules/ToolCallRouter.js +25 -12
  69. package/runtime/arcane/modules/YouTubeMedia.js +6 -5
  70. package/schemas/arcane-app-bundle.schema.json +13 -78
  71. package/schemas/arcane-app.schema.json +9 -25
  72. package/schemas/arcane-lock.schema.json +18 -151
  73. package/schemas/arcane-package.schema.json +2 -16
  74. package/schemas/native-build-plan.schema.json +119 -122
  75. package/src/app-descriptor.mjs +75 -132
  76. package/src/application-tests.mjs +200 -0
  77. package/src/cli/main.mjs +27 -46
  78. package/src/constants.mjs +3 -4
  79. package/src/dev-server.mjs +30 -324
  80. package/src/doctor.mjs +92 -154
  81. package/src/dom-event-instrumentation.mjs +55 -147
  82. package/src/errors.mjs +2 -3
  83. package/src/event-manager.mjs +239 -624
  84. package/src/event-queue.mjs +3 -3
  85. package/src/import-map.mjs +273 -1028
  86. package/src/index.mjs +14 -16
  87. package/src/installed-sdk-runtime.mjs +40 -62
  88. package/src/integrated-provider-loader.mjs +53 -382
  89. package/src/mail-api.mjs +0 -2
  90. package/src/mail-server.mjs +224 -580
  91. package/src/mail.mjs +4 -10
  92. package/src/native-plan.mjs +163 -598
  93. package/src/native-provider-loader.mjs +104 -1063
  94. package/src/packager/core.mjs +485 -3229
  95. package/src/process.mjs +5 -10
  96. package/src/release-bundle.mjs +292 -2405
  97. package/src/runtime.mjs +76 -396
  98. package/src/scaffold.mjs +30 -80
  99. package/src/sdk-browser-runtime.mjs +70 -626
  100. package/src/source-server.mjs +588 -0
  101. package/src/targets/index.mjs +78 -188
  102. package/src/templates/workspace-template.mjs +19 -135
  103. package/src/testing-loader.mjs +164 -0
  104. package/src/testing.mjs +1 -1
  105. package/src/toolchain.mjs +131 -544
  106. package/src/update-check.mjs +26 -64
  107. package/src/workspace-operation-lock.mjs +139 -430
  108. package/src/workspace-runtime.mjs +112 -779
  109. package/src/workspace.mjs +40 -302
  110. package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +0 -218
  111. package/browser-runtime/ai/ARCANE_AI_BROWSER_SPEECH_COMPONENTS.json +0 -203
  112. package/browser-runtime/ai/ARCANE_AI_BROWSER_WASM_COMPONENTS.json +0 -80
  113. package/browser-runtime/ai/internal/sha256.mjs +0 -166
  114. package/docs/architecture.md +0 -344
  115. package/docs/compatibility.md +0 -36
  116. package/docs/event-manager.md +0 -294
  117. package/docs/platform-targets.md +0 -108
  118. package/docs/publishing.md +0 -201
  119. package/docs/reference/README.md +0 -187
  120. package/docs/reference/ai/browser-speech-package-authority.json +0 -835
  121. package/docs/reference/ai/browser-speech.md +0 -1252
  122. package/docs/reference/ai/browser-wasm.md +0 -530
  123. package/docs/reference/arcane-ollama.md +0 -288
  124. package/docs/reference/availability-and-normalization.md +0 -183
  125. package/docs/reference/behavioral-testing.md +0 -133
  126. package/docs/reference/cli.md +0 -779
  127. package/docs/reference/core/README.md +0 -62
  128. package/docs/reference/core/arcane-ai-contracts.md +0 -907
  129. package/docs/reference/core/arcane-api.md +0 -601
  130. package/docs/reference/core/arcane-entities.md +0 -65
  131. package/docs/reference/core/arcane-events.md +0 -134
  132. package/docs/reference/core/ollama-module.md +0 -181
  133. package/docs/reference/core/reference/arcane-api/ai-and-ollama.md +0 -1909
  134. package/docs/reference/core/reference/arcane-api/applications-terminal-capabilities.md +0 -1057
  135. package/docs/reference/core/reference/arcane-api/core-and-events.md +0 -320
  136. package/docs/reference/core/reference/arcane-api/filesystem-storage-preferences-appearance.md +0 -610
  137. package/docs/reference/core/reference/arcane-api/namespaces.md +0 -1157
  138. package/docs/reference/core/reference/arcane-api/platform-installation-users-system.md +0 -1423
  139. package/docs/reference/core/reference/arcane-api/session-provisioning-diagnostics-development.md +0 -315
  140. package/docs/reference/event-manager.md +0 -1511
  141. package/docs/reference/inventory/package-api.json +0 -3284
  142. package/docs/reference/inventory/runtime-components.json +0 -1011
  143. package/docs/reference/inventory/runtime-entities.json +0 -26
  144. package/docs/reference/inventory/runtime-modules.json +0 -1431
  145. package/docs/reference/mail.md +0 -316
  146. package/docs/reference/protocols.md +0 -677
  147. package/docs/reference/runtime-components.md +0 -1366
  148. package/docs/reference/runtime-entities.md +0 -303
  149. package/docs/reference/runtime-modules.md +0 -2960
  150. package/docs/reference/sdk-api.md +0 -6694
  151. package/docs/roadmap.md +0 -79
  152. package/docs/work-amplification.md +0 -129
  153. 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 boundedText(value,fallback,maximum=500){
4
- const text=typeof value==='string'?value.trim():'';
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).slice(2,10)}`;
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 Object.freeze({
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 Object.freeze({
38
+ return {
41
39
  blocked:true,
42
40
  decisionId:decisionId(),
43
41
  decidedAt:new Date().toISOString(),
44
42
  policyGeneration,
45
- ruleId:boundedText(rule?.id,'unknown-rule',80),
43
+ secure:true,
44
+ ruleId:completeText(rule?.id,'unknown-rule'),
46
45
  ruleType,
47
46
  target:target.url,
48
- matchedValue:ruleType==='domain'?boundedText(rule?.domain,target.hostname,253):boundedText(rule?.cidr,target.hostname,160),
49
- reason:Object.freeze({
50
- code:boundedText(rule?.reason?.code,'global-deny',80),
51
- title:boundedText(rule?.reason?.title,'Blocked by the global deny policy',120),
52
- description:boundedText(rule?.reason?.description,'Arcane stopped this navigation before sending a request.')
53
- }),
54
- source:Object.freeze({
55
- id:boundedText(rule?.source?.id,'arcane-policy',80),
56
- label:boundedText(rule?.source?.label,'Arcane global policy',120),
57
- reference:boundedText(rule?.source?.reference,'',500)||null
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 Object.freeze({
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:Object.freeze({
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:Object.freeze({
76
+ },
77
+ source:{
78
78
  id:'arcane-runtime',
79
79
  label:'Arcane policy safety boundary',
80
- reference:boundedText(error?.code||'','',120)||null
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
- try{
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
- :Object.freeze({
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,Object.freeze({intent:boundedText(context.intent,'embedded',32)}));
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=Object.freeze({
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=Object.freeze({
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=Object.freeze({
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 knownKeys(value,allowed,label,code='BROWSER_TEST_INVALID_OPTIONS'){
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
- const normalized=value.trim();
82
- if(!normalized) fail(`${label} cannot be empty.`,'BROWSER_TEST_INVALID_DESCRIPTOR');
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
- .replace(/[\u0000-\u001f\u007f]+/g,' ')
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,{maxTests,timeoutMs}){
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 Object.freeze(value.map((item,index)=>{
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
- knownKeys(item,new Set(['id','name','run','timeoutMs']),`Test descriptor ${index+1}`,'BROWSER_TEST_INVALID_DESCRIPTOR');
104
- const id=descriptorText(item.id,`Test descriptor ${index+1} id`,128);
105
- if(!TEST_ID_PATTERN.test(id)) fail(`Test descriptor ${index+1} has an invalid id.`,'BROWSER_TEST_INVALID_DESCRIPTOR');
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 Object.freeze({
82
+ return {
83
+ ...item,
111
84
  id,
112
- name:descriptorText(item.name,`Test descriptor ${index+1} name`,256),
85
+ name:descriptorText(item.name,`Test descriptor ${index+1} name`),
113
86
  run:item.run,
114
- timeoutMs:boundedInteger(item.timeoutMs??timeoutMs,`Test descriptor ${index+1} timeoutMs`,{minimum:10,maximum:timeoutMs}),
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 Object.freeze({
131
- maxTests,
99
+ return {
132
100
  now,
133
- tests:normalizeTests(input.tests??[],{maxTests,timeoutMs}),
134
- timeoutMs,
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 Object.freeze({context:input.context,signal});
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.round(Math.max(0,end-start)*1000)/1000;
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 timeoutError(milliseconds){
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 Object.freeze({status:'pass',message:'Passed.'});
176
+ return {status:'pass',message:'Passed.'};
223
177
  }
224
178
  if(value===false){
225
- return Object.freeze({status:'fail',message:'The check returned false.',code:'BROWSER_TEST_ASSERTION'});
179
+ return {status:'fail',message:'The check returned false.',code:'BROWSER_TEST_ASSERTION'};
226
180
  }
227
181
  if(!isPlainRecord(value)){
228
- return Object.freeze({status:'fail',message:'The check returned an invalid result.',code:'BROWSER_TEST_INVALID_RESULT'});
182
+ return {status:'fail',message:'The check returned an invalid result.',code:'BROWSER_TEST_INVALID_RESULT'};
229
183
  }
230
- const unknown=Object.keys(value).find(key=>!new Set(['message','status']).has(key));
231
- if(unknown||!RESULT_STATUSES.has(value.status)){
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 Object.freeze({status:value.status,message:resultMessage(value.message,fallback)});
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 Object.freeze({status:'skip',message:resultMessage(error.message,'Skipped.'),code:error.code});
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 Object.freeze({status:'skip',message:resultMessage(error.message,'The run was aborted.'),code:error.code});
196
+ return {status:'skip',message:resultMessage(error.message,'The run was aborted.'),code:error.code,error};
244
197
  }
245
- return Object.freeze({
198
+ return {
246
199
  status:'fail',
247
200
  message:resultMessage(error?.message,'The check failed.'),
248
- code:typeof error?.code==='string'?error.code.slice(0,64):'BROWSER_TEST_ERROR',
249
- errorName:resultMessage(error?.name,'Error').slice(0,128),
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 Object.freeze({
208
+ return {
209
+ ...outcome,
255
210
  id:test.id,
256
211
  name:test.name,
257
- status:outcome.status,
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 Object.freeze({
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 Object.freeze({
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 Object.freeze({
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 Object.freeze({
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. Per-test abort signals and timeout races bound
348
- * cooperative asynchronous orchestration; callbacks must still avoid blocking
349
- * the page and stop work they started after their abort signal fires.
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.freeze(Object.values(BROWSER_TEST_SUITE_EVENT_TYPES))
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 Object.freeze(this.#tests.map(test=>Object.freeze({
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
- const compatibilityDetail=Object.freeze(detail);
389
- return this.#events.dispatch(type,compatibilityDetail,{
345
+ return this.#events.dispatch(type,detail,{
390
346
  operationId,
391
- publicDetail:browserTestPublicDetail(type,compatibilityDetail)
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=Object.freeze({controller,operationId});
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:Object.freeze({id:descriptor.id,name:descriptor.name,timeoutMs:descriptor.timeoutMs}),
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 runWithTimeout(signal=>descriptor.run(Object.freeze({
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
- })),{signal:controller.signal,timeoutMs:descriptor.timeoutMs});
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=Object.freeze({
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=Object.freeze({
437
+ const summary={
482
438
  status,
483
439
  totals,
484
440
  durationMs:elapsed(this.#now,suiteStart),
485
- results:Object.freeze(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=Object.freeze({sqrt:Math.sqrt,abs:Math.abs,sin:Math.sin,cos:Math.cos,tan:Math.tan,log:Math.log10,ln:Math.log});
5
- const CONSTANTS=Object.freeze({pi:Math.PI,e:Math.E});
6
- export const CALCULATOR_ENGINE_ERROR_CODES=Object.freeze({
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||source.length>512)throw new TypeError('Expression must contain 1-512 characters.');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;}
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:Object.freeze(['calculator-result','calculator-error'])});}
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:Object.freeze({result:calculation.result})
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
- Object.freeze({expression:compatibilityExpression(expression),error}),
73
- {operationId,publicDetail:Object.freeze({code})}
72
+ {expression:compatibilityExpression(expression),error},
73
+ {operationId,publicDetail:{code,error,expression:compatibilityExpression(expression)}}
74
74
  );
75
75
  throw error;
76
76
  }