pptb-standard-sample-tool 1.7.3 → 1.7.5

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/README.md CHANGED
@@ -56,6 +56,7 @@ This compiles the TypeScript source in `src/` to JavaScript in `dist/`.
56
56
 
57
57
  ```
58
58
  html-sample/
59
+ ├── dotnet/ # C# worker tool and shared Core project
59
60
  ├── src/
60
61
  │ ├── app.ts # Main application logic (TypeScript)
61
62
  │ ├── features/ # UI feature modules (terminal, filesystem, etc.)
@@ -170,6 +171,142 @@ const dataverse: typeof window.dataverseAPI = window.dataverseAPI;
170
171
  3. **Rebuild:** Run `npm run build`
171
172
  4. **Reload Tool:** In Power Platform Tool Box, close and reopen the tool
172
173
 
174
+ ## .NET Worker Test
175
+
176
+ The **.NET Worker Test (Account Summary)** section is beside Query Records. The
177
+ worker summarizes up to ten accounts by country. The TypeScript callback builds
178
+ the FetchXML and calls Dataverse; the C# worker groups the returned rows and
179
+ returns `{ totalAccounts, countries: [{ country, count }] }`. The direct account
180
+ query remains independent and continues to use saved `demo.fetchxml`. Start,
181
+ summarize, cancel and stop are explicit; worker status/output is separate.
182
+ Loading the tool does not start .NET. The headless entry point is unchanged and
183
+ never starts a worker.
184
+
185
+ The complete C# sample is in `dotnet/Worker`; it has no separate Core project.
186
+ Workers run as the current OS user and **are not sandboxed**. No tokens, secrets
187
+ or credential environment variables are sent by this tool.
188
+
189
+ Tool code uses PPTB's worker session API rather than implementing a JSON-RPC
190
+ transport:
191
+
192
+ ```typescript
193
+ const worker = await toolboxAPI.workers.connect("sample", {
194
+ requests: {
195
+ "dataverse/getAccounts": (params, context) => {
196
+ const top = typeof params === "object" && params !== null && "top" in params ? params.top : undefined;
197
+ if (typeof top !== "number" || !Number.isSafeInteger(top) || top < 1 || top > 100) throw new Error("Expected an account limit from 1 to 100");
198
+ if (context.isCancellationRequested) throw new Error("Cancelled");
199
+ const fetchXml = `<fetch top="${top}"><entity name="account"><attribute name="name" /><attribute name="address1_country" /></entity></fetch>`;
200
+ return dataverseAPI.fetchXmlQuery(fetchXml).then((result) => ({ value: result.value }));
201
+ },
202
+ },
203
+ notifications: {
204
+ progress: (message) => showStatus(String(message)),
205
+ },
206
+ });
207
+
208
+ await worker.ready;
209
+ const result = await worker.request("accounts/summarizeByCountry", { top: 10 });
210
+ await worker.stop();
211
+ ```
212
+
213
+ PPTB owns JSON-RPC framing, readiness buffering, request correlation,
214
+ cancellation, and transport disposal. Tool authors provide only the
215
+ worker-initiated callbacks they need and call named worker methods.
216
+
217
+ ### Build And Pack
218
+
219
+ Use the PR7 desktop development build, Node.js, and a compatible installed .NET
220
+ 10 SDK/runtime. This sample pins `@pptb/types@1.2.7-beta.6`, the current npm
221
+ beta tag, for `workers.connect()`. The shrinkwrap locks that exact release;
222
+ dependency changes use npm and the existing shrinkwrap workflow (`npm run finalize-package`).
223
+ The beta still depends on `@pptb/validate@1.0.4`, which warns that `workers` is
224
+ unrecognized. That validator result is not worker-declaration validation; the
225
+ current desktop worker validator remains authoritative.
226
+
227
+ From this `html-sample` directory:
228
+
229
+ ```sh
230
+ npm install
231
+ npm test
232
+ npm run pack:worker
233
+ ```
234
+
235
+ The pack command writes `PPTB.Sample.Query.Worker.0.1.6.nupkg` to `dotnet/feed/`.
236
+ The exact declaration is `PPTB.Sample.Query.Worker@0.1.6`, command
237
+ `pptb-sample-query-worker`, targeting `net10.0`. Inspect the `.nupkg`: it must
238
+ contain `Worker.dll`, managed dependencies, runtime/dependency manifests and
239
+ `DotnetToolSettings.xml`. Changing package bytes requires a fresh
240
+ worker version and a matching `packageVersion` update in `pptb.config.json`. Do
241
+ not overwrite a version with new bytes. `platforms: ["all"]` is the PPTB support
242
+ matrix, not qualification evidence.
243
+
244
+ ### Load Local Tool
245
+
246
+ From the desktop-app repo in PowerShell, point to this repo's generated feed in
247
+ the main-process environment:
248
+
249
+ ```powershell
250
+ $env:PPTB_DOTNET_LOCAL_NUGET_FEED = (Resolve-Path "../sample-tools/new/html-sample/dotnet/feed").Path
251
+ pnpm run dev
252
+ ```
253
+
254
+ On macOS/Linux shells:
255
+
256
+ ```sh
257
+ PPTB_DOTNET_LOCAL_NUGET_FEED="$(cd ../sample-tools/new/html-sample/dotnet/feed && pwd -P)" pnpm run dev
258
+ ```
259
+
260
+ First run `npm run pack:worker` in `html-sample`. The feed must already exist
261
+ and be a flat directory containing only regular files, with no symlink path
262
+ components, subdirectories, symlink files or hard-linked files. On other OSes,
263
+ choose an absolute canonical directory satisfying the same rules. Set the
264
+ environment variable before launching PPTB; restart the development app after
265
+ changing it. The previously packed package is accessible at the canonical path
266
+ without moving or repacking it.
267
+
268
+ The feed requires both the Vite development bundle's main-only
269
+ `PPTB_DEVELOPER_BUILD=1` marker and an unpackaged app. Production-mode unpackaged
270
+ and packaged builds reject it. Never add the feed to the tool manifest or
271
+ renderer. Registry, marketplace and npm-debug installs remain nuget.org-only;
272
+ only the exact local worker package ID is mapped to this feed, and dependencies
273
+ remain on nuget.org. Source path/package digest changes require fresh consent.
274
+
275
+ 1. Use **Load Local Tool** to load `sample-tools/new/html-sample` and select an
276
+ authenticated primary Dataverse connection.
277
+ 2. Start worker, review native-code consent, and wait for Worker ready.
278
+ 3. Select **Summarize Accounts by Country (Top 10)**. TypeScript sends
279
+ `accounts/summarizeByCountry({ top: 10 })`; C# calls the registered
280
+ `dataverse/getAccounts({ top: 10 })` callback. TypeScript builds the bounded
281
+ FetchXML and returns only account names and country values; C# groups those
282
+ rows and returns `{ totalAccounts, countries: [{ country, count }] }`.
283
+ 4. Cancel a pending query, then stop explicitly. Cancellation is cooperative;
284
+ the existing Dataverse API does not abort an in-flight HTTP request. No request
285
+ is automatically replayed. A failed stop disables start/query until cleanup
286
+ is verified; Stop worker remains available to retry.
287
+ 5. Also check consent denial/revocation, tool close and app quit.
288
+
289
+ ### Verification Boundaries
290
+
291
+ `npm test` builds the browser bundle and runs simulated worker transport tests;
292
+ it does not launch C#. There were no pre-existing automated sample test scripts;
293
+ the interactive security suites remain available in the UI.
294
+
295
+ For an opt-in **real packaged C# stdio** test, extract the `.nupkg` into a temporary
296
+ directory, then run from `new/html-sample` with absolute paths:
297
+
298
+ ```sh
299
+ node tests/dotnetWorker.smoke.mjs /usr/local/share/dotnet/dotnet \
300
+ /absolute/path/to/extracted/tools/net10.0/any/Worker.dll
301
+ ```
302
+
303
+ This launches native code explicitly with a minimal credential-free environment.
304
+ It checks initialize, account summary, reverse callbacks with fixture data, progress,
305
+ cancellation and EOF exit. It does **not** test live Dataverse, local-feed restore,
306
+ PPTB consent/broker, or other OS/architecture targets. The full Load Local Tool
307
+ smoke remains a separate manual gate; do not infer PR7 completion from either
308
+ simulated transport tests or this real-stdio fixture.
309
+
173
310
  ## Security Testing Guidance
174
311
 
175
312
  Use the built-in **Run Security Probe** button in the Terminal section to validate terminal exposure with safe commands only.
package/dist/app.js CHANGED
@@ -1,4 +1,5 @@
1
1
  /// <reference types="@pptb/types" />
2
+ import { createDotnetWorkerFeature } from "./features/dotnetWorker.js";
2
3
  import { createFileSystemFeature } from "./features/filesystem.js";
3
4
  import { createTerminalFeature } from "./features/terminal.js";
4
5
  import { createSecuritySuites } from "./security/suites.js";
@@ -27,6 +28,7 @@ let preventCloseActive = false;
27
28
  let securitySuites = null;
28
29
  let terminalFeature = null;
29
30
  let fileSystemFeature = null;
31
+ let dotnetWorkerFeature = null;
30
32
  let inputEntityName = null;
31
33
  let fetchXmlFromLaunchContext = null;
32
34
  const powerPlatformNamespaces = [
@@ -71,6 +73,12 @@ async function initialize() {
71
73
  log,
72
74
  getCurrentConnection: () => currentConnection,
73
75
  });
76
+ dotnetWorkerFeature = createDotnetWorkerFeature({
77
+ toolbox,
78
+ dataverse,
79
+ getCurrentConnection: () => currentConnection,
80
+ log,
81
+ });
74
82
  // Initialize security suites (used by UI buttons)
75
83
  securitySuites = createSecuritySuites({
76
84
  toolbox,
@@ -310,6 +318,10 @@ function setupEventHandlers() {
310
318
  document.getElementById("query-accounts-secondary-btn")?.addEventListener("click", queryAccountsSecondary);
311
319
  document.getElementById("query-accounts-third-btn")?.addEventListener("click", queryAccountsThird);
312
320
  document.getElementById("query-contacts-querydata-btn")?.addEventListener("click", queryContactQueryData);
321
+ document.getElementById("worker-start-btn")?.addEventListener("click", () => dotnetWorkerFeature?.start());
322
+ document.getElementById("worker-query-btn")?.addEventListener("click", () => dotnetWorkerFeature?.query());
323
+ document.getElementById("worker-cancel-btn")?.addEventListener("click", () => dotnetWorkerFeature?.cancel());
324
+ document.getElementById("worker-stop-btn")?.addEventListener("click", () => dotnetWorkerFeature?.stop());
313
325
  // CRUD buttons
314
326
  document.getElementById("create-contact-btn")?.addEventListener("click", createContact);
315
327
  document.getElementById("update-contact-btn")?.addEventListener("click", updateContact);
@@ -547,9 +559,20 @@ function handleTerminalOutput(data) {
547
559
  function handleCommandCompleted(data) {
548
560
  requireTerminalFeature().handleCommandCompleted(data);
549
561
  }
550
- /**
551
- * Query accounts from Dataverse
552
- */
562
+ async function getAccountFetchXml() {
563
+ const saved = await getSetting("demo.fetchxml");
564
+ return saved && saved.trim().length > 0
565
+ ? saved
566
+ : `<fetch top="10">
567
+ <entity name="account">
568
+ <attribute name="name" />
569
+ <attribute name="accountid" />
570
+ <attribute name="emailaddress1" />
571
+ <attribute name="telephone1" />
572
+ <order attribute="name" />
573
+ </entity>
574
+ </fetch>`;
575
+ }
553
576
  async function queryAccounts() {
554
577
  if (!currentConnection) {
555
578
  await showNotification("No Connection", "Please connect to a Dataverse environment", "warning");
@@ -559,21 +582,7 @@ async function queryAccounts() {
559
582
  const output = document.getElementById("query-output");
560
583
  if (output)
561
584
  output.textContent = "Querying accounts...\n";
562
- // If user saved a FetchXML in settings, prefer that; otherwise use default
563
- const saved = await getSetting("demo.fetchxml");
564
- const fetchXml = saved && saved.trim().length > 0
565
- ? saved
566
- : `
567
- <fetch top="10">
568
- <entity name="account">
569
- <attribute name="name" />
570
- <attribute name="accountid" />
571
- <attribute name="emailaddress1" />
572
- <attribute name="telephone1" />
573
- <order attribute="name" />
574
- </entity>
575
- </fetch>
576
- `.trim();
585
+ const fetchXml = await getAccountFetchXml();
577
586
  const result = await dataverse.fetchXmlQuery(fetchXml);
578
587
  if (output) {
579
588
  output.textContent = `Found ${result.value.length} account(s):\n\n`;
@@ -0,0 +1,142 @@
1
+ // src/features/dotnetWorker.ts
2
+ function createDotnetWorkerFeature(deps) {
3
+ const startButton = document.getElementById("worker-start-btn");
4
+ const runButton = document.getElementById("worker-query-btn");
5
+ const cancelButton = document.getElementById("worker-cancel-btn");
6
+ const stopButton = document.getElementById("worker-stop-btn");
7
+ const status = document.getElementById("worker-status");
8
+ const output = document.getElementById("worker-output");
9
+ let worker;
10
+ let queryRunning = false;
11
+ let cancelRequested = false;
12
+ let state = "stopped";
13
+ function render() {
14
+ startButton.disabled = state !== "stopped" || !deps.toolbox.workers;
15
+ runButton.disabled = state !== "ready" || queryRunning;
16
+ cancelButton.disabled = state !== "ready" || !queryRunning;
17
+ stopButton.disabled = !worker || state === "stopping";
18
+ }
19
+ function report(error, prefix) {
20
+ const message = `${prefix}: ${error instanceof Error ? error.message : String(error)}`;
21
+ status.textContent = message;
22
+ deps.log(message, "error");
23
+ }
24
+ function clearWorker() {
25
+ worker?.dispose();
26
+ worker = void 0;
27
+ state = "stopped";
28
+ render();
29
+ }
30
+ async function start() {
31
+ if (state !== "stopped") return;
32
+ state = "starting";
33
+ render();
34
+ status.textContent = "Requesting native-code consent and starting worker...";
35
+ output.textContent = "";
36
+ try {
37
+ const started = await deps.toolbox.workers.connect("sample", {
38
+ requests: {
39
+ "dataverse/getAccounts": async (params, context) => {
40
+ const top = typeof params === "object" && params !== null && "top" in params ? params.top : void 0;
41
+ if (typeof top !== "number" || !Number.isSafeInteger(top) || top < 1 || top > 100) throw new Error("Expected an account limit from 1 to 100");
42
+ if (!deps.getCurrentConnection()) throw new Error("No active Dataverse connection");
43
+ if (context.isCancellationRequested) throw new Error("Request cancelled");
44
+ const fetchXml = `<fetch top="${top}"><entity name="account"><attribute name="name" /><attribute name="address1_country" /></entity></fetch>`;
45
+ const result = await deps.dataverse.fetchXmlQuery(fetchXml);
46
+ if (context.isCancellationRequested) throw new Error("Request cancelled");
47
+ return { value: result.value };
48
+ }
49
+ },
50
+ notifications: {
51
+ progress: (message) => {
52
+ if (state === "ready" && typeof message === "string") status.textContent = message;
53
+ }
54
+ },
55
+ onExit: (snapshot) => {
56
+ clearWorker();
57
+ status.textContent = snapshot.failure ? `Worker exited: ${snapshot.failure}` : "Worker exited";
58
+ }
59
+ });
60
+ worker = started;
61
+ void started.ready.catch(() => void 0);
62
+ await started.ready;
63
+ if (worker !== started || state !== "starting") return;
64
+ state = "ready";
65
+ status.textContent = "Worker ready";
66
+ render();
67
+ } catch (error) {
68
+ if (worker) {
69
+ try {
70
+ await worker.stop();
71
+ clearWorker();
72
+ } catch (stopError) {
73
+ state = "blocked";
74
+ render();
75
+ report(stopError, "Startup failed; cleanup unverified. Retry Stop worker");
76
+ return;
77
+ }
78
+ }
79
+ clearWorker();
80
+ report(error, "Worker startup failed");
81
+ }
82
+ }
83
+ async function query() {
84
+ if (state !== "ready" || !worker || queryRunning) return;
85
+ const activeWorker = worker;
86
+ queryRunning = true;
87
+ cancelRequested = false;
88
+ render();
89
+ output.textContent = "";
90
+ status.textContent = "Preparing account summary in worker...";
91
+ try {
92
+ if (!deps.getCurrentConnection()) throw new Error("No active Dataverse connection");
93
+ if (worker !== activeWorker || state !== "ready") return;
94
+ const result = await activeWorker.request("accounts/summarizeByCountry", { top: 10 });
95
+ if (worker !== activeWorker || state !== "ready") return;
96
+ if (cancelRequested) status.textContent = "Query cancelled";
97
+ else {
98
+ output.textContent = JSON.stringify(result, null, 2);
99
+ status.textContent = "Worker query completed";
100
+ }
101
+ } catch (error) {
102
+ if (worker === activeWorker && state === "ready") {
103
+ if (cancelRequested) status.textContent = "Query cancelled";
104
+ else report(error, "Worker query failed");
105
+ }
106
+ } finally {
107
+ queryRunning = false;
108
+ render();
109
+ }
110
+ }
111
+ function cancel() {
112
+ if (!worker || state !== "ready" || !queryRunning) return;
113
+ cancelRequested = true;
114
+ worker.cancel();
115
+ status.textContent = "Cancellation requested; an in-flight Dataverse call may still finish";
116
+ cancelButton.disabled = true;
117
+ }
118
+ async function stop() {
119
+ if (!worker || state === "stopping") return;
120
+ const stoppingWorker = worker;
121
+ state = "stopping";
122
+ stoppingWorker.cancel();
123
+ render();
124
+ status.textContent = "Stopping worker...";
125
+ try {
126
+ await stoppingWorker.stop();
127
+ if (worker === stoppingWorker) clearWorker();
128
+ status.textContent = "Worker stopped";
129
+ } catch (error) {
130
+ if (worker !== stoppingWorker) return;
131
+ state = "blocked";
132
+ render();
133
+ report(error, "Worker cleanup unverified. Retry Stop worker");
134
+ }
135
+ }
136
+ render();
137
+ if (!deps.toolbox.workers) status.textContent = "Worker API unavailable in this PPTB build";
138
+ return { start, query, cancel, stop };
139
+ }
140
+ export {
141
+ createDotnetWorkerFeature
142
+ };
@@ -0,0 +1,194 @@
1
+ import { AbstractMessageReader, AbstractMessageWriter, CancellationTokenSource, createMessageConnection, Disposable, ResponseError } from "vscode-jsonrpc/browser";
2
+ class WorkerMessageReader extends AbstractMessageReader {
3
+ maxQueuedMessages;
4
+ maxQueuedBytes;
5
+ callback;
6
+ queued = [];
7
+ queuedBytes = 0;
8
+ failed = false;
9
+ failure;
10
+ released = false;
11
+ constructor(maxQueuedMessages, maxQueuedBytes) {
12
+ super();
13
+ this.maxQueuedMessages = maxQueuedMessages;
14
+ this.maxQueuedBytes = maxQueuedBytes;
15
+ }
16
+ listen(callback) {
17
+ if (this.callback)
18
+ throw new Error("Worker RPC reader is already listening");
19
+ this.callback = callback;
20
+ return Disposable.create(() => {
21
+ this.callback = undefined;
22
+ });
23
+ }
24
+ flush() {
25
+ if (!this.callback)
26
+ return;
27
+ this.released = true;
28
+ for (const message of this.queued.splice(0))
29
+ this.callback(message);
30
+ this.queuedBytes = 0;
31
+ if (this.failure) {
32
+ this.fireError(this.failure);
33
+ this.fireClose();
34
+ }
35
+ }
36
+ accept(message) {
37
+ if (this.callback && this.released) {
38
+ this.callback(message);
39
+ return;
40
+ }
41
+ if (this.failed)
42
+ return;
43
+ let bytes;
44
+ try {
45
+ bytes = new TextEncoder().encode(JSON.stringify(message)).byteLength;
46
+ }
47
+ catch (error) {
48
+ this.failed = true;
49
+ this.failure = error instanceof Error ? error : new Error(String(error));
50
+ return;
51
+ }
52
+ if (this.queued.length >= this.maxQueuedMessages || this.queuedBytes + bytes > this.maxQueuedBytes) {
53
+ this.failed = true;
54
+ this.queued.length = 0;
55
+ this.queuedBytes = 0;
56
+ this.failure = new Error("Worker RPC startup message buffer limit exceeded");
57
+ return;
58
+ }
59
+ this.queued.push(message);
60
+ this.queuedBytes += bytes;
61
+ }
62
+ close() {
63
+ this.fireClose();
64
+ }
65
+ dispose() {
66
+ this.queued.length = 0;
67
+ this.queuedBytes = 0;
68
+ super.dispose();
69
+ }
70
+ }
71
+ class WorkerMessageWriter extends AbstractMessageWriter {
72
+ transport;
73
+ constructor(transport) {
74
+ super();
75
+ this.transport = transport;
76
+ }
77
+ async write(message) {
78
+ try {
79
+ await this.transport.send(message);
80
+ }
81
+ catch (error) {
82
+ this.fireError(error, message);
83
+ throw error;
84
+ }
85
+ }
86
+ end() {
87
+ this.fireClose();
88
+ }
89
+ }
90
+ export function createWorkerRpcClient(transport) {
91
+ const reader = new WorkerMessageReader(64, 4 * 1024 * 1024);
92
+ const writer = new WorkerMessageWriter(transport);
93
+ const connection = createMessageConnection(reader, writer);
94
+ connection.listen();
95
+ let disposed = false;
96
+ let unsubscribeMessage = () => undefined;
97
+ let unsubscribeExit = () => undefined;
98
+ const close = () => {
99
+ if (disposed)
100
+ return;
101
+ disposed = true;
102
+ unsubscribeMessage();
103
+ unsubscribeExit();
104
+ reader.close();
105
+ writer.end();
106
+ connection.dispose();
107
+ };
108
+ unsubscribeMessage = transport.onMessage((message) => {
109
+ if (!disposed)
110
+ reader.accept(message);
111
+ });
112
+ unsubscribeExit = transport.onExit(close);
113
+ let listening = false;
114
+ return {
115
+ connection,
116
+ listen: () => {
117
+ if (disposed)
118
+ throw new Error("Worker RPC client is disposed");
119
+ if (listening)
120
+ return;
121
+ listening = true;
122
+ reader.flush();
123
+ },
124
+ dispose: close,
125
+ };
126
+ }
127
+ export async function startWorkerSession(options) {
128
+ const workers = options.toolbox.workers;
129
+ if (!workers)
130
+ throw new Error("This PPTB build does not expose the worker API");
131
+ const started = await workers.start(options.workerId);
132
+ const rpc = createWorkerRpcClient({
133
+ send: (message) => workers.send(started.handle, message),
134
+ onMessage: (listener) => workers.onMessage(started.handle, listener),
135
+ onExit: (listener) => workers.onExit(started.handle, listener),
136
+ });
137
+ let currentQuery;
138
+ let disposed = false;
139
+ const dispose = () => {
140
+ if (disposed)
141
+ return;
142
+ disposed = true;
143
+ currentQuery?.cancel();
144
+ currentQuery?.dispose();
145
+ currentQuery = undefined;
146
+ unsubscribeExit();
147
+ rpc.dispose();
148
+ };
149
+ rpc.connection.onRequest("dataverse/fetchXml", async (fetchXml, token) => {
150
+ if (typeof fetchXml !== "string" || !fetchXml.trim())
151
+ throw new ResponseError(-32602, "Expected FetchXML text");
152
+ const result = await options.fetchXmlQuery(fetchXml, token);
153
+ if (token.isCancellationRequested)
154
+ throw new ResponseError(-32800, "Request cancelled");
155
+ return JSON.parse(JSON.stringify({ value: result.value }));
156
+ });
157
+ rpc.connection.onNotification("progress", (message) => {
158
+ if (typeof message === "string")
159
+ options.onProgress?.(message);
160
+ });
161
+ const unsubscribeExit = workers.onExit(started.handle, (snapshot) => {
162
+ dispose();
163
+ options.onExit?.(snapshot);
164
+ });
165
+ rpc.listen();
166
+ return {
167
+ ready: started.ready,
168
+ query: async (fetchXml) => {
169
+ if (disposed)
170
+ throw new Error("Worker session has ended");
171
+ if (currentQuery)
172
+ throw new Error("A worker query is already running");
173
+ const query = new CancellationTokenSource();
174
+ currentQuery = query;
175
+ try {
176
+ return await rpc.connection.sendRequest("query", fetchXml, query.token);
177
+ }
178
+ finally {
179
+ query.dispose();
180
+ if (currentQuery === query)
181
+ currentQuery = undefined;
182
+ }
183
+ },
184
+ cancel: () => currentQuery?.cancel(),
185
+ stop: async () => {
186
+ if (disposed)
187
+ return;
188
+ currentQuery?.cancel();
189
+ await workers.stop(started.handle);
190
+ dispose();
191
+ },
192
+ dispose,
193
+ };
194
+ }
package/dist/index.html CHANGED
@@ -161,6 +161,19 @@
161
161
  <div id="query-output-third" class="output"></div>
162
162
  </div>
163
163
 
164
+ <div class="example-group">
165
+ <h3>.NET Worker Test (Account Summary)</h3>
166
+ <p class="info-box warning">Native code runs as your OS user and is not sandboxed. The worker groups account rows by country.</p>
167
+ <div class="button-group">
168
+ <button id="worker-start-btn" class="btn">Start worker</button>
169
+ <button id="worker-query-btn" class="btn btn-primary" disabled>Summarize Accounts by Country (Top 10)</button>
170
+ <button id="worker-cancel-btn" class="btn btn-warning" disabled>Cancel summary</button>
171
+ <button id="worker-stop-btn" class="btn btn-error" disabled>Stop worker</button>
172
+ </div>
173
+ <div id="worker-status" role="status" aria-live="polite">Worker stopped</div>
174
+ <div id="worker-output" class="output"></div>
175
+ </div>
176
+
164
177
  <div class="example-group">
165
178
  <h3>CRUD Operations</h3>
166
179
  <div class="input-group">