@syncmatters/connector-sdk 1.0.19 → 1.0.21
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/docs/09-testing.md +177 -17
- package/docs/10-style-and-pitfalls.md +3 -1
- package/docs/13-file-fields.md +30 -0
- package/docs/15-example-test-file.md +69 -3
- package/lib/file-provider-buffer.d.ts +2 -0
- package/lib/file-provider-disk.d.ts +2 -0
- package/lib/file-provider-string.d.ts +2 -0
- package/package.json +3 -3
package/docs/09-testing.md
CHANGED
|
@@ -73,7 +73,8 @@ export default class Test {
|
|
|
73
73
|
|
|
74
74
|
// prepare hooks - create/arrange the data a given test needs, return what it should expect:
|
|
75
75
|
// listPrepare, idsFilterPrepare, checkpointFilterStep1Prepare / Step2Prepare,
|
|
76
|
-
// matchFilterPrepare, relatedFilterPrepare, upsertPrepare
|
|
76
|
+
// matchFilterPrepare, relatedFilterPrepare, rowFiltersPrepare, upsertPrepare
|
|
77
|
+
// and, for what is not shaped like a feature, customChecks() (below)
|
|
77
78
|
}
|
|
78
79
|
```
|
|
79
80
|
|
|
@@ -110,10 +111,14 @@ To disable those tests use `cannotTestReason` instead
|
|
|
110
111
|
([15-example-test-file.md](./15-example-test-file.md) shows the worked semantics):
|
|
111
112
|
|
|
112
113
|
- `listPrepare` / `idsFilterPrepare` — arrange rows and return what the suite should expect.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
- `rowFiltersPrepare` — one `RowFilterData { rowFilter, expectedRowIds, fields?, expect? }` per
|
|
115
|
+
case, for the filters `meta()` declares in `queryFilter.rowFilter`. The harness queries each
|
|
116
|
+
with its `rowFilter` (and `fields`), in order, and the rows must be exactly `expectedRowIds`;
|
|
117
|
+
the first case that fails decides the object's `rowFilter` status (`Row not returned`,
|
|
118
|
+
`Returned unexpected row`, or the `expect` failures below), details under `rowFilter`. Row
|
|
119
|
+
filters have no `ObjectFeatures` flag, so a suite without the hook (or one that returns
|
|
120
|
+
`undefined`) reads `Not tested` with the reason and the object is NOT made Partial; with
|
|
121
|
+
neither the hook nor declared row filters the status is `Not supported`.
|
|
117
122
|
- `checkpointFilterStep1Prepare` / `Step2Prepare` — the two-step round-trip test of your
|
|
118
123
|
differential contract; see [Checkpoint testing](#checkpoint-testing-what-the-two-steps-prove)
|
|
119
124
|
below, it is the most-misimplemented pair.
|
|
@@ -130,6 +135,17 @@ To disable those tests use `cannotTestReason` instead
|
|
|
130
135
|
contract under "What each test needs" below. Use `withIssueSimulators` /
|
|
131
136
|
`UpsertIssueSimulator` (`verifyFields`, expected issue `type` + `fatal`) to deliberately
|
|
132
137
|
exercise your `upsertClean` issue reporting.
|
|
138
|
+
- **File fields in `upsertPrepare`.** When the object has an upsert field of `type: "file"`,
|
|
139
|
+
every `upsertPrepare` call (upsert, delete and upsertClean tests) carries `withFileField`
|
|
140
|
+
set to its path (a field with `constraints: { mandatory: "add" }` is preferred, else the
|
|
141
|
+
first file field). Put an `SDK.FileProvider` at that path in `insert` (and `update`), and
|
|
142
|
+
return a **fresh provider on every call**: the harness passes the connector a clone
|
|
143
|
+
(`SDK.utilities.clone(row, { withFileProviders: true })`), so the connector may close what
|
|
144
|
+
it is handed, and the harness closes your originals and its clones once the test has
|
|
145
|
+
verified them ([13-file-fields.md](./13-file-fields.md)). A file field listed in `verifyFields` is
|
|
146
|
+
compared by bytes with `FileProvider.equals()`: the query-back must return a provider with
|
|
147
|
+
the same content. The harness never adds the file field to the query-back on its own,
|
|
148
|
+
since that downloads the content; list it in `verifyFields` or `fields` to opt in.
|
|
133
149
|
- delete features support soft-delete verification — `isDeleted: { path, valueWhenDeleted }`
|
|
134
150
|
checks a flag field instead of row absence, and `afterDeleteMaxIndexWaitTimeMs` tolerates
|
|
135
151
|
APIs whose deletions surface asynchronously.
|
|
@@ -137,7 +153,7 @@ To disable those tests use `cannotTestReason` instead
|
|
|
137
153
|
## Expected values and errors: the `expect` clause
|
|
138
154
|
|
|
139
155
|
The data every prepare hook returns (`ListData`, `IdsFilterData`, `CheckpointStep1Data`,
|
|
140
|
-
`CheckpointStep2Data`, `MatchData`, `RelationshipData`) takes an optional `expect`, for what a
|
|
156
|
+
`CheckpointStep2Data`, `MatchData`, `RelationshipData`, `RowFilterData`) takes an optional `expect`, for what a
|
|
141
157
|
row-id comparison cannot prove: that a value comes back in the right form, or that a call fails
|
|
142
158
|
the way it must.
|
|
143
159
|
|
|
@@ -212,10 +228,24 @@ succeeded, the rows it returned — is recorded in the details either way. What
|
|
|
212
228
|
| `CheckpointStep2Data` | the step 2 query, from the checkpoint step 1 emitted |
|
|
213
229
|
| `MatchData` | that match query |
|
|
214
230
|
| `RelationshipData` | the related query (the 'from' row is still fetched by `idsFilter` and must exist) |
|
|
231
|
+
| `RowFilterData` | that row-filter query |
|
|
215
232
|
|
|
216
233
|
`expectedRowIds` / `expectedMatchRowIds` / `expectedRelatedRowIds` may be empty when `error` is
|
|
217
234
|
set.
|
|
218
235
|
|
|
236
|
+
A timeout is the typical row-filter case: SQL Server's statement filter with a `WAITFOR` and a
|
|
237
|
+
`requestTimeoutOverride` shorter than the wait must fail with the driver's timeout, not hang or
|
|
238
|
+
return rows:
|
|
239
|
+
|
|
240
|
+
```js
|
|
241
|
+
/** @type {SDKTest.RowFilterData} */
|
|
242
|
+
const timeoutCase = {
|
|
243
|
+
rowFilter: { where: { clause: "1 = 1; WAITFOR DELAY '00:00:05'" }, requestTimeoutOverride: 1000 },
|
|
244
|
+
expectedRowIds: [],
|
|
245
|
+
expect: { error: { code: "ETIMEOUT" } },
|
|
246
|
+
};
|
|
247
|
+
```
|
|
248
|
+
|
|
219
249
|
**Upsert**: `UpsertData.expect` is `{ insert?, update? }`, each an assertion list for this
|
|
220
250
|
data's own row as queried back after that phase. `upsertPrepare` runs once per row, and the
|
|
221
251
|
row's key is only assigned by the insert, so there is nothing to key by. The asserted paths are
|
|
@@ -263,6 +293,59 @@ This catches a connector that keys its rows on one column and tags another, whic
|
|
|
263
293
|
only a sync finds. A spec without `query.key` (anything generated before it existed) skips
|
|
264
294
|
these checks.
|
|
265
295
|
|
|
296
|
+
## Custom checks: `customChecks()`
|
|
297
|
+
|
|
298
|
+
Some tests are not shaped like an object feature: a free statement with typed parameters,
|
|
299
|
+
500 sequential statements to prove connections are released, a connection-level timeout, an API
|
|
300
|
+
quirk. They go in `customChecks()`, which returns named checks the harness runs **after the
|
|
301
|
+
feature tests, in declaration order**, each with its own status, duration and details:
|
|
302
|
+
|
|
303
|
+
```js
|
|
304
|
+
/** @type {SDKTest.TestFeatureProvider["customChecks"]} */
|
|
305
|
+
async customChecks() {
|
|
306
|
+
return [
|
|
307
|
+
{
|
|
308
|
+
id: "exec-typed-parameters", // stable: keys the result and details, and is what --check names
|
|
309
|
+
name: "$Exec binds typed parameters",
|
|
310
|
+
objectId: "$Exec", // gated like a feature; omit for a connection-level check
|
|
311
|
+
timeoutMs: 30000, // default 60 000
|
|
312
|
+
run: async ({ connection, meta, record }) => {
|
|
313
|
+
const rows = await collect(connection.query["$Exec"]({ rowFilter: { statement: "SELECT @p0 AS v", parameters: [{ name: "p0", sqlType: "int", value: 42 }] } }));
|
|
314
|
+
record("returned", { rows }); // a detail on the check, under the usual caps
|
|
315
|
+
if (rows[0]?.data.v !== 42) throw new Error(`expected 42, got ${rows[0]?.data.v}`);
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
];
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
- **Status**: `Passed`, `Failed: <message>` (the first line of what `run` threw; the full error
|
|
323
|
+
is kept as the check's `fatalError`), or `Not tested` with `notTestedReason`. Results are
|
|
324
|
+
`CustomCheckResult { id, name, status, notTestedReason?, durationMs, fatalError? }`, on the
|
|
325
|
+
object's result for a check with an `objectId` and on the run's for one without.
|
|
326
|
+
- **Details**: `record(message, data)` adds a detail under `details["check:<id>"]`; the harness
|
|
327
|
+
adds one saying how the check ended. Same caps and ~48-hour retention as every feature's.
|
|
328
|
+
- **Selection**: a check with an `objectId` whose object is not on the connection (not selected
|
|
329
|
+
on a two-phase connection, or not returned by `meta()`) reads `Not tested` with the reason —
|
|
330
|
+
it does not fail. The object need not be one of `objectIds()`; if it is, the harness's object
|
|
331
|
+
check fails the run first, as it does for any tested object. A check without an `objectId` is
|
|
332
|
+
connection-level and always runs. `ctx.meta` is the check object's metadata.
|
|
333
|
+
- **Timeout**: `timeoutMs` (default 60 000) is capped at the run's remaining budget when the run
|
|
334
|
+
has one. A check still running at its timeout fails (`Failed: Timed out after <n>ms`), what it
|
|
335
|
+
records afterwards is dropped, and the next check runs.
|
|
336
|
+
- **Roll-up**: a failed check fails its object and the run; a check not tested makes a Passed
|
|
337
|
+
object Partial and keeps a run that would pass at `Partial: Not all features/objects can be
|
|
338
|
+
tested`.
|
|
339
|
+
- **Which checks a run reaches**: all of them on a full run; with `--object`, the
|
|
340
|
+
connection-level checks plus the selected objects'; none with `--only
|
|
341
|
+
connection|meta|discover|query`; exactly the named ones with `--check <id>` (repeatable),
|
|
342
|
+
whatever else the run tests. An id the suite does not declare fails as `No such check in the
|
|
343
|
+
test suite`.
|
|
344
|
+
|
|
345
|
+
A check is the escape hatch, not the default. If the same check turns up in a second connector,
|
|
346
|
+
it belongs in the harness as a feature; and a value assertion on rows a feature already fetches
|
|
347
|
+
belongs in that feature's `expect`, where it reports on the feature.
|
|
348
|
+
|
|
266
349
|
## Fixtures on two-phase connections: fixed names, seeded in `before()`
|
|
267
350
|
|
|
268
351
|
A two-phase test connection collects only the objects it selected. So a fixture object — a
|
|
@@ -274,7 +357,7 @@ connection right after it.
|
|
|
274
357
|
The procedure:
|
|
275
358
|
|
|
276
359
|
1. Create the fixtures once, with fixed names (the SQLServer suite's `dbo.sm_test_*` pattern).
|
|
277
|
-
2. Run `sm test --only discover`.
|
|
360
|
+
2. Run `sm connector test --only discover`.
|
|
278
361
|
3. Select the fixtures on the test connection.
|
|
279
362
|
4. Run the suite.
|
|
280
363
|
|
|
@@ -406,25 +489,31 @@ The harness always executes platform-side. What each entry point actually runs:
|
|
|
406
489
|
|
|
407
490
|
| Command | What runs |
|
|
408
491
|
| ---------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
409
|
-
| `sm test` | The FULL suite — `connection.test()` + `metaRefresh()` + every object feature above. Identical to the web UI's connector test. |
|
|
410
|
-
| `sm test --only connection` | `connection.test()` only — credentials check, nothing else. The harness has NOT run. |
|
|
411
|
-
| `sm test --only meta` | `connection.metaRefresh()` only — for a connection with a selection, that is `meta()` over the selected objects. |
|
|
412
|
-
| `sm test --only discover` | `connection.discover()` only (two-phase connectors) — reports the catalogue size, which settings it returned overrides for, and how long it took, then refreshes the selection and checks every selected object is in the catalogue. |
|
|
413
|
-
| `sm test --only query` | Every object's plain list query only — no ids/checkpoint/match/related, no writes.
|
|
492
|
+
| `sm connector test` | The FULL suite — `connection.test()` + `metaRefresh()` + every object feature above + every custom check. Identical to the web UI's connector test. |
|
|
493
|
+
| `sm connector test --only connection` | `connection.test()` only — credentials check, nothing else. The harness has NOT run. |
|
|
494
|
+
| `sm connector test --only meta` | `connection.metaRefresh()` only — for a connection with a selection, that is `meta()` over the selected objects. |
|
|
495
|
+
| `sm connector test --only discover` | `connection.discover()` only (two-phase connectors) — reports the catalogue size, which settings it returned overrides for, and how long it took, then refreshes the selection and checks every selected object is in the catalogue. |
|
|
496
|
+
| `sm connector test --only query` | Every object's plain list query only — no ids/checkpoint/match/related, no writes, no custom checks. |
|
|
497
|
+
| `sm connector test --object <id>` | The full suite for the named object(s), plus the connection-level custom checks and those object(s)' checks. |
|
|
498
|
+
| `sm connector test --check <id>` | Only the named custom check(s) (repeat the flag); no object features unless `--object` is given too. The fast loop for one failing check. |
|
|
414
499
|
| Web UI (connector → test results → run) | Same engine, feature selection via the run dialog. |
|
|
415
500
|
|
|
501
|
+
(The old short name `sm test` now prints the replacement and exits 2.)
|
|
502
|
+
|
|
416
503
|
Each feature test records the inputs it ran with and what the connector returned — pass or
|
|
417
504
|
fail — onto the saved result as diagnostic `details` (the ids an `idsFilter` ran with and the
|
|
418
|
-
rows returned; expected vs actual rows per match rule / relationship; upserted rows
|
|
419
|
-
query-back
|
|
505
|
+
rows returned; expected vs actual rows per match rule / relationship / row filter; upserted rows
|
|
506
|
+
and their query-back; what each custom check recorded). The web UI shows custom checks in a
|
|
507
|
+
"Custom checks" table under each object and under the connection, and `sm connector test`
|
|
508
|
+
prints one line per check after the object lines. Review them on the web UI's test results screen instead of re-running with extra
|
|
420
509
|
logging. Payloads over the size budget are dropped and flagged `truncated`; details hold real
|
|
421
510
|
connection data and are scrubbed ~48 hours after the run (statuses are kept).
|
|
422
511
|
|
|
423
512
|
Do not mistake a passing `--only connection` or `--only query` for suite coverage — only the
|
|
424
|
-
full run exercises the prepare hooks and write features. `sm test` needs a developer token
|
|
513
|
+
full run exercises the prepare hooks and write features. `sm connector test` needs a developer token
|
|
425
514
|
with the `scripts:read`, `connections:read` and `scripts:execute` scopes; a 403 from the CLI
|
|
426
515
|
names the missing one. The dev loop around it: `sm validate` (workspace structure), `checkJs`
|
|
427
|
-
type checking against the SDK types, `sm push`, then `sm test`. Keep `test()` cheap and
|
|
516
|
+
type checking against the SDK types, `sm push`, then `sm connector test`. Keep `test()` cheap and
|
|
428
517
|
`meta()` deterministic so the suite stays fast.
|
|
429
518
|
|
|
430
519
|
Three workspace facts that bite here:
|
|
@@ -442,8 +531,79 @@ Three workspace facts that bite here:
|
|
|
442
531
|
`sm connector features` (or the web UI's feature-generate action) after `meta()` changes;
|
|
443
532
|
new relationships/match rules will NOT be tested until you do, because the generated
|
|
444
533
|
`relationshipsTo`/`matchRules` spec still reflects the old metadata. The loop is:
|
|
445
|
-
edit `meta()` → `sm push` → `sm connector features` → `sm test`.
|
|
534
|
+
edit `meta()` → `sm push` → `sm connector features` → `sm connector test`.
|
|
446
535
|
- The typed test connection (the `API.connections.<connection_name>.Connection` `@typedef` in
|
|
447
536
|
the `mod Test.mjs` example above) resolves from **generated** typings:
|
|
448
537
|
`types/connections/*.d.ts` in a CLI workspace (`sm types` refreshes them; `sm pull` runs it
|
|
449
538
|
automatically) — never hand-write these.
|
|
539
|
+
|
|
540
|
+
## Migrating an old test module to `mod Test.mjs`
|
|
541
|
+
|
|
542
|
+
Many connectors still carry a second suite in `files/Scripts/Tests/mod <Conn>.mjs`, run by the
|
|
543
|
+
loop in `_tst Connector.mjs`: every `test*` method is called, logged as Success or Fail, and the
|
|
544
|
+
run throws at the end. The harness replaces it. Port each method to the first of these that
|
|
545
|
+
fits, and delete the old module and its `_tst` entry when the last method has moved:
|
|
546
|
+
|
|
547
|
+
1. **An existing feature.** Most methods (list, by id, by ids, by last-modified, match rules,
|
|
548
|
+
related rows, insert/update, the `test_*_metadata` methods) are already what the harness
|
|
549
|
+
tests. Port them as prepare data (`listPrepare`, `idsFilterPrepare`, ...), not as checks;
|
|
550
|
+
metadata methods are covered by the generated spec and the `fieldMetadata` feature.
|
|
551
|
+
2. **An `expect` clause.** A method that checks a returned value's form (a rowversion as hex, a
|
|
552
|
+
GUID, a decimal's value, a column that must not be echoed) or a call that must fail (a
|
|
553
|
+
timeout, a rejected id, a `*_not_exist` query) becomes `expect.rows` or `expect.error` on the
|
|
554
|
+
feature data that makes that call.
|
|
555
|
+
3. **A custom check**, for what is left: a method that is not shaped like a feature.
|
|
556
|
+
|
|
557
|
+
Porting a method to a custom check:
|
|
558
|
+
|
|
559
|
+
```js
|
|
560
|
+
// old: files/Scripts/Tests/mod SQLServer v2.mjs
|
|
561
|
+
async testExec() {
|
|
562
|
+
const rows = await collect(this.conn.query["$Exec"]({ rowFilter: { statement: "SELECT 1 AS one" } }));
|
|
563
|
+
if (rows[0].data.one !== 1) throw new Error("exec did not return 1");
|
|
564
|
+
this.log.info("exec ok");
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// new: in mod Test.mjs
|
|
568
|
+
async customChecks() {
|
|
569
|
+
return [
|
|
570
|
+
{
|
|
571
|
+
id: "exec",
|
|
572
|
+
name: "$Exec returns a statement's rows",
|
|
573
|
+
objectId: "$Exec",
|
|
574
|
+
run: async ({ connection, record }) => {
|
|
575
|
+
const rows = await collect(connection.query["$Exec"]({ rowFilter: { statement: "SELECT 1 AS one" } }));
|
|
576
|
+
record("returned", { rows });
|
|
577
|
+
if (rows[0]?.data.one !== 1) throw new Error(`exec returned ${JSON.stringify(rows[0]?.data)}`);
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
];
|
|
581
|
+
}
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
- The method body moves into `run`; `this.conn` becomes `ctx.connection` (or the suite's own
|
|
585
|
+
typed connection from `before()`), `this.log.info` of what you want to review later becomes
|
|
586
|
+
`ctx.record`, and the `throw` stays: throwing is how a check fails.
|
|
587
|
+
- Give it a stable `id` (the old method name without `test` is a good one) and, when it
|
|
588
|
+
exercises one object, that `objectId`, so an unselected object reads Not tested instead of
|
|
589
|
+
failing.
|
|
590
|
+
- `onlyTest*` / `skipTest*` renames are gone: `--check <id>` runs one check, and a check that
|
|
591
|
+
must not run is deleted or given a reason in its own code, not renamed.
|
|
592
|
+
- A method that loops or times (500 sequential statements, a large read) keeps its loop; put
|
|
593
|
+
the timing in `record`, not in a pass/fail threshold, and set `timeoutMs` above what it needs.
|
|
594
|
+
- Setup the old module did in its constructor or `before()` moves to the suite's `before()`,
|
|
595
|
+
which runs once for features and checks alike. **Seed rows there, never objects**.
|
|
596
|
+
|
|
597
|
+
What cannot be ported:
|
|
598
|
+
|
|
599
|
+
- **Creating a table, custom object or other schema per test** (the `SMTST<random>` pattern).
|
|
600
|
+
A two-phase test connection only collects the objects it selected, so an object made during
|
|
601
|
+
the run is never on it. Use a fixed fixture instead ([above](#fixtures-on-two-phase-connections-fixed-names-seeded-in-before)):
|
|
602
|
+
create it once with a fixed name, discover, select it, and let the check or feature seed rows.
|
|
603
|
+
Tests that only prove "a table can be created" go.
|
|
604
|
+
- **Anything whose pass/fail is a timing threshold.** A threshold needs a per-environment budget
|
|
605
|
+
the harness does not have; record the timing as a detail instead.
|
|
606
|
+
- **Methods that depend on another method having run first.** Checks run in declaration order,
|
|
607
|
+
but each must stand alone (a `--check` run may run only one); share state through `before()`.
|
|
608
|
+
- **Platform tests** (`mod ScriptAPI`, `Storage`, `Webhook`, `Drives`, ...) are not connector
|
|
609
|
+
tests and stay where they are, with `_tst Main.mjs`.
|
|
@@ -119,7 +119,9 @@
|
|
|
119
119
|
The two you will use constantly (they appear in nearly every fleet connector):
|
|
120
120
|
|
|
121
121
|
- `SDK.utilities.clone(value)` — deep clone. Use before mutating anything you received or
|
|
122
|
-
return (metadata, rows, options) — shared references are how callers get corrupted.
|
|
122
|
+
return (metadata, rows, options) — shared references are how callers get corrupted. A
|
|
123
|
+
`FileProvider` in the value becomes an unreadable shim unless you pass
|
|
124
|
+
`{ withFileProviders: true }` ([13-file-fields.md](./13-file-fields.md)).
|
|
123
125
|
- `SDK.utilities.tryGet(() => deeply.nested.maybe.missing)` — returns the value or
|
|
124
126
|
`undefined`, never throws. The idiom for prodding uncertain API payloads:
|
|
125
127
|
`const sig = tryGet(() => options.payload.headers["x-signature"][0]);`
|
package/docs/13-file-fields.md
CHANGED
|
@@ -82,10 +82,34 @@ SDK.utilities.fileProvider({ file: { path: tempPath, deleteOnClose: true } });
|
|
|
82
82
|
| `save(path?)` | persist to disk, returns the path (pair with `SDK.utilities.tempFile`) |
|
|
83
83
|
| `length()` | size in bytes (e.g. for a `size` field or Content-Length header) |
|
|
84
84
|
| `close()` | release the resource — **always, in a `finally`** |
|
|
85
|
+
| `clone()` | an independent provider over the same bytes; close it like any other |
|
|
86
|
+
| `equals(other)` | `true` when both hold the same bytes; `false` if either side is closed |
|
|
85
87
|
|
|
86
88
|
Parsing structured file content: `SDK.utilities.csvReader({ source: { fileProvider } })`
|
|
87
89
|
(same idea for `xlsxReader`).
|
|
88
90
|
|
|
91
|
+
## Cloning and comparing
|
|
92
|
+
|
|
93
|
+
`clone()` returns an independent provider: closing the original does not close the clone, and
|
|
94
|
+
the clone **must be closed too**. A clone of a temp-file provider (`deleteOnClose`) shares the
|
|
95
|
+
file by reference count, so the file is removed only when the last holder closes. A provider
|
|
96
|
+
dropped without `close()` is released when the runtime collects it, which may be much later, so
|
|
97
|
+
closing stays the rule. Cloning a closed provider throws `ResourceAlreadyClosed`.
|
|
98
|
+
|
|
99
|
+
Deep-copying a row that holds a file:
|
|
100
|
+
|
|
101
|
+
- `SDK.utilities.clone(row)` copies the data and replaces each provider with a **shim**. The
|
|
102
|
+
shim serialises like a provider (`"[object FileProvider]"`) and needs no close, but every read
|
|
103
|
+
(`stream()`, `length()`, `save()`, `blob()`, `formDataValue()`, `clone()`, `equals()`) throws
|
|
104
|
+
`SCRIPT_FILE_PROVIDER_SHALLOW_CLONE`. That is what a snapshot for logging wants.
|
|
105
|
+
- `SDK.utilities.clone(row, { withFileProviders: true })` clones each provider with `clone()`,
|
|
106
|
+
so the copy can read the files. The code that made the copy owns its providers and must
|
|
107
|
+
close them.
|
|
108
|
+
|
|
109
|
+
`equals(other)` compares bytes: lengths first, then both streams chunk by chunk, without
|
|
110
|
+
reading whole files into memory. It resolves `false` rather than throwing when either side has
|
|
111
|
+
been closed.
|
|
112
|
+
|
|
89
113
|
## Upserting: the file arrives as a field value
|
|
90
114
|
|
|
91
115
|
The upsert row carries a `FileProvider` at the file field's position. Stream it out and close
|
|
@@ -121,3 +145,9 @@ async upsert(options) {
|
|
|
121
145
|
3. **Close what you open.** Providers can be backed by temp files; `close()` in a `finally`
|
|
122
146
|
on both the query and upsert paths.
|
|
123
147
|
|
|
148
|
+
## Testing
|
|
149
|
+
|
|
150
|
+
The connector test harness exercises file fields through the upsert, delete and upsertClean
|
|
151
|
+
tests: it asks `upsertPrepare` for a file at `withFileField`, passes the connector a clone, and compares a file
|
|
152
|
+
field listed in `verifyFields` by bytes. See
|
|
153
|
+
[09-testing.md](./09-testing.md#preparecleanup-hooks-and-their-data-types).
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The test-file counterpart to [12-example-connector.md](./12-example-connector.md): the complete
|
|
4
4
|
`mod Test.mjs` + `mod ObjectTestFeatures.mjs` pair for the Acme connector, on one page. The
|
|
5
|
-
platform's connector test suite AND the CLI's `sm test` both require a file of sidecar
|
|
5
|
+
platform's connector test suite AND the CLI's `sm connector test` both require a file of sidecar
|
|
6
6
|
type `"connectortest"` in the connector's folder — **a connector without one cannot be
|
|
7
7
|
run-tested at all**. Harness conventions and hook semantics live in
|
|
8
8
|
[09-testing.md](./09-testing.md); this page shows them applied end to end against Acme's three
|
|
@@ -39,7 +39,7 @@ generated module via `module_script_file_paths`:
|
|
|
39
39
|
}
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
Keep exactly one `"connectortest"` file per connector folder — `sm test` resolves the test
|
|
42
|
+
Keep exactly one `"connectortest"` file per connector folder — `sm connector test` resolves the test
|
|
43
43
|
module from the folder beside the connector's entry file, and several candidates are ambiguous
|
|
44
44
|
(`<Name>.test.mjs` wins when present).
|
|
45
45
|
|
|
@@ -180,6 +180,8 @@ export default class TestFeatureProvider {
|
|
|
180
180
|
#connection;
|
|
181
181
|
/** @type string */
|
|
182
182
|
#fixtureCompanyId = ""; // shared parent row for contact test data
|
|
183
|
+
/** @type string */
|
|
184
|
+
#fixtureVendorId = ""; // the sandbox's one vendor company, for the companyType row filter
|
|
183
185
|
|
|
184
186
|
/** @type SDKTest.TestFeatureProvider["before"] */
|
|
185
187
|
async before(ctx) {
|
|
@@ -213,6 +215,10 @@ export default class TestFeatureProvider {
|
|
|
213
215
|
const company = await this.#connection.queryOne.companies();
|
|
214
216
|
if (!company) throw new Error("Sandbox has no companies - seed at least one before testing");
|
|
215
217
|
this.#fixtureCompanyId = company.meta.key;
|
|
218
|
+
for await (const row of this.#connection.query.companies()) {
|
|
219
|
+
if (row.data.name === "sm_fixture Vendor") this.#fixtureVendorId = row.meta.key;
|
|
220
|
+
}
|
|
221
|
+
if (!this.#fixtureVendorId) throw new Error("Sandbox fixture 'sm_fixture Vendor' is missing - create it once as a vendor");
|
|
216
222
|
}
|
|
217
223
|
|
|
218
224
|
/** @type SDKTest.TestFeatureProvider["after"] */
|
|
@@ -494,6 +500,57 @@ export default class TestFeatureProvider {
|
|
|
494
500
|
await this.#deleteRows(options.meta.id, options.inserted.map((r) => r.meta.key));
|
|
495
501
|
}
|
|
496
502
|
|
|
503
|
+
/** @type SDKTest.TestFeatureProvider["rowFiltersPrepare"] */
|
|
504
|
+
async rowFiltersPrepare(options) {
|
|
505
|
+
// companies declares one row filter, companyType (12). Acme's API cannot set a company's
|
|
506
|
+
// type, so the sandbox holds a fixed fixture instead: exactly one vendor, named
|
|
507
|
+
// "sm_fixture Vendor", created once and never touched by the suite (the fixed-fixture rule, 09).
|
|
508
|
+
// before() looked its key up; every other sandbox company is a customer.
|
|
509
|
+
if (options.meta.id !== "companies") return undefined;
|
|
510
|
+
return [
|
|
511
|
+
{
|
|
512
|
+
rowFilter: { companyType: "vendor" },
|
|
513
|
+
expectedRowIds: [this.#fixtureVendorId],
|
|
514
|
+
expect: { rows: { "*": [{ path: [{ path: "name" }], equals: "sm_fixture Vendor" }] } },
|
|
515
|
+
},
|
|
516
|
+
];
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** @type SDKTest.TestFeatureProvider["customChecks"] */
|
|
520
|
+
async customChecks() {
|
|
521
|
+
// what is not shaped like an object feature; each check reports its own status (09)
|
|
522
|
+
return [
|
|
523
|
+
{
|
|
524
|
+
// connection-level (no objectId): always runs
|
|
525
|
+
id: "sequential-tests",
|
|
526
|
+
name: "20 sequential test() calls all succeed",
|
|
527
|
+
run: async ({ record }) => {
|
|
528
|
+
const started = Date.now();
|
|
529
|
+
for (let i = 0; i < 20; i++) {
|
|
530
|
+
const result = await this.#connection.test();
|
|
531
|
+
if (!result.success) throw new Error(`test() call ${i + 1} failed: ${result.message}`);
|
|
532
|
+
}
|
|
533
|
+
record("20 test() calls", { durationMs: Date.now() - started }); // timing is a detail, not a threshold
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
// object-level: reads Not tested, not Failed, if contacts is not selected on the connection
|
|
538
|
+
id: "contacts-page-walk",
|
|
539
|
+
name: "Listing three pages of contacts returns no duplicate keys",
|
|
540
|
+
objectId: "contacts",
|
|
541
|
+
timeoutMs: 120000,
|
|
542
|
+
run: async ({ record }) => {
|
|
543
|
+
const seen = new Set();
|
|
544
|
+
for await (const row of this.#connection.query.contacts({ limit: 300 })) {
|
|
545
|
+
if (seen.has(row.meta.key)) throw new Error(`key ${row.meta.key} returned twice`);
|
|
546
|
+
seen.add(row.meta.key);
|
|
547
|
+
}
|
|
548
|
+
record(`${seen.size} distinct contacts`);
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
];
|
|
552
|
+
}
|
|
553
|
+
|
|
497
554
|
// === private helpers ======================================================
|
|
498
555
|
|
|
499
556
|
/** Create one prefixed row; creating counts as a modification for checkpoint tests.
|
|
@@ -570,7 +627,7 @@ Nothing in this pair changes when a connector gains `discover()`
|
|
|
570
627
|
regenerates `mod ObjectTestFeatures.mjs` without it — it warns about the catalogue entries the
|
|
571
628
|
connection did not select, so read that output rather than assuming a shrunken generated file is
|
|
572
629
|
the connector's doing. Widen the test connection's selection rather than scoping the object out;
|
|
573
|
-
`sm test --only discover` ([09](./09-testing.md#running-the-suite)) checks the catalogue against
|
|
630
|
+
`sm connector test --only discover` ([09](./09-testing.md#running-the-suite)) checks the catalogue against
|
|
574
631
|
the selection and prints what is missing.
|
|
575
632
|
|
|
576
633
|
The same rule applies to fixture objects a suite owns, such as a sandbox table or a custom
|
|
@@ -633,3 +690,12 @@ async before({ connection }) {
|
|
|
633
690
|
10. **`query.key` is generated, like `upsert.key`.** It names the field whose value IS
|
|
634
691
|
`row.meta.key`, and the list and idsFilter tests check every row against it. If the
|
|
635
692
|
generated file lacks it, it predates the check: regenerate.
|
|
693
|
+
11. **Row filters are tested from a fixed fixture.** `rowFiltersPrepare` has to name the exact
|
|
694
|
+
rows a filter returns, so it needs a sandbox whose answer it knows: here one vendor company
|
|
695
|
+
created once and looked up in `before()`, never created by the run. Without the hook
|
|
696
|
+
companies' row filter reads `Not tested`, which does not make the object Partial.
|
|
697
|
+
12. **Custom checks are for what no feature expresses**, and each reports on its own:
|
|
698
|
+
`sequential-tests` on the connection, `contacts-page-walk` under contacts (Not tested, not
|
|
699
|
+
Failed, if contacts is not selected). Throwing fails a check; `record` puts what you want to
|
|
700
|
+
review later on the result; timings are details, never thresholds. Rerun one with
|
|
701
|
+
`sm connector test Acme --check contacts-page-walk`.
|
|
@@ -7,5 +7,7 @@ export declare class FileProviderBuffer implements FileProvider {
|
|
|
7
7
|
blob(fileName?: string): Promise<Blob>;
|
|
8
8
|
save(path?: string): Promise<string>;
|
|
9
9
|
close(): Promise<void>;
|
|
10
|
+
clone(): FileProvider;
|
|
11
|
+
equals(other: FileProvider): Promise<boolean>;
|
|
10
12
|
toJSON(): string;
|
|
11
13
|
}
|
|
@@ -7,5 +7,7 @@ export declare class FileProviderDisk implements FileProvider {
|
|
|
7
7
|
blob(fileName?: string): Promise<Blob>;
|
|
8
8
|
save(path?: string): Promise<string>;
|
|
9
9
|
close(): Promise<void>;
|
|
10
|
+
clone(): FileProvider;
|
|
11
|
+
equals(other: FileProvider): Promise<boolean>;
|
|
10
12
|
toJSON(): string;
|
|
11
13
|
}
|
|
@@ -7,5 +7,7 @@ export declare class FileProviderString implements FileProvider {
|
|
|
7
7
|
blob(fileName?: string): Promise<Blob>;
|
|
8
8
|
save(path?: string): Promise<string>;
|
|
9
9
|
close(): Promise<void>;
|
|
10
|
+
clone(): FileProvider;
|
|
11
|
+
equals(other: FileProvider): Promise<boolean>;
|
|
10
12
|
toJSON(): string;
|
|
11
13
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncmatters/connector-sdk",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.21",
|
|
4
4
|
"description": "TypeScript type definitions for the SyncMatters connector SDK (types only - connectors execute on the SyncMatters platform)",
|
|
5
5
|
"types": "./index.d.ts",
|
|
6
6
|
"exports": {
|
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
"license": "MIT",
|
|
13
13
|
"author": "SyncMatters",
|
|
14
14
|
"homepage": "https://syncmatters.com",
|
|
15
|
-
"typesContentHash": "
|
|
15
|
+
"typesContentHash": "cbce2eec280ba657cbe2b0aa321b46b3578ab203622d95a3030beb8e5180cd78",
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@types/node": "*",
|
|
18
|
-
"@syncmatters/script-api": "^1.0.
|
|
18
|
+
"@syncmatters/script-api": "^1.0.23"
|
|
19
19
|
}
|
|
20
20
|
}
|