@company-semantics/contracts 51.1.0 → 51.3.0
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/package.json +1 -1
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +87 -3
- package/src/comments/README.md +175 -32
- package/src/comments/__tests__/README.md +60 -1
- package/src/comments/__tests__/anchor-constants.test.ts +161 -0
- package/src/comments/__tests__/create.test.ts +300 -0
- package/src/comments/__tests__/receipt.test.ts +143 -0
- package/src/comments/__tests__/resolve.test.ts +391 -0
- package/src/comments/__tests__/verify.test.ts +262 -0
- package/src/comments/anchor-constants.ts +61 -0
- package/src/comments/anchor.ts +20 -11
- package/src/comments/create.ts +128 -0
- package/src/comments/index.ts +36 -0
- package/src/comments/receipt.ts +146 -0
- package/src/comments/resolve.ts +261 -0
- package/src/comments/verify.ts +128 -0
- package/src/execution/__tests__/registry.test.ts +89 -0
- package/src/execution/kinds.ts +21 -1
- package/src/execution/registry.ts +73 -0
- package/src/generated/openapi-routes.ts +1 -0
- package/src/index.ts +73 -0
- package/src/message-parts/__tests__/confirmation.test.ts +3 -0
- package/src/message-parts/confirmation.ts +3 -0
package/package.json
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
|
|
2
|
-
export const SPEC_HASH = '
|
|
3
|
-
export const SPEC_HASH_FULL = '
|
|
2
|
+
export const SPEC_HASH = 'd0171aedf623' as const;
|
|
3
|
+
export const SPEC_HASH_FULL = 'd0171aedf623604dc721fb42b626ebc019afcc2fda9be59e1f06360cd6a4fcca' as const;
|
package/src/api/generated.ts
CHANGED
|
@@ -3616,6 +3616,23 @@ export interface paths {
|
|
|
3616
3616
|
patch?: never;
|
|
3617
3617
|
trace?: never;
|
|
3618
3618
|
};
|
|
3619
|
+
"/api/comments/threads/{threadId}/accept-apply": {
|
|
3620
|
+
parameters: {
|
|
3621
|
+
query?: never;
|
|
3622
|
+
header?: never;
|
|
3623
|
+
path?: never;
|
|
3624
|
+
cookie?: never;
|
|
3625
|
+
};
|
|
3626
|
+
get?: never;
|
|
3627
|
+
put?: never;
|
|
3628
|
+
/** Apply a claimed suggestion and record the accept, on one commit */
|
|
3629
|
+
post: operations["acceptSuggestionApply"];
|
|
3630
|
+
delete?: never;
|
|
3631
|
+
options?: never;
|
|
3632
|
+
head?: never;
|
|
3633
|
+
patch?: never;
|
|
3634
|
+
trace?: never;
|
|
3635
|
+
};
|
|
3619
3636
|
"/api/comments/threads/{threadId}/accept-release": {
|
|
3620
3637
|
parameters: {
|
|
3621
3638
|
query?: never;
|
|
@@ -4737,7 +4754,7 @@ export interface components {
|
|
|
4737
4754
|
summary: {
|
|
4738
4755
|
executionId: string;
|
|
4739
4756
|
/** @enum {string} */
|
|
4740
|
-
kind: "integration.connect" | "integration.disconnect" | "profile.update" | "slack.send" | "data.ingest" | "data.scope" | "system.cleanup" | "member.changeManager" | "companyMd.ingest";
|
|
4757
|
+
kind: "integration.connect" | "integration.disconnect" | "profile.update" | "slack.send" | "data.ingest" | "data.scope" | "system.cleanup" | "member.changeManager" | "companyMd.ingest" | "companyMd.commentPass" | "semantic.transform" | "companyMd.importContextDoc";
|
|
4741
4758
|
target: {
|
|
4742
4759
|
/** @constant */
|
|
4743
4760
|
type: "slack";
|
|
@@ -6542,6 +6559,15 @@ export interface components {
|
|
|
6542
6559
|
SuggestionClaimRequest: {
|
|
6543
6560
|
claimToken: string;
|
|
6544
6561
|
};
|
|
6562
|
+
/** @description The accepted thread and the document mutation that landed with it, on one commit. */
|
|
6563
|
+
SuggestionAcceptAppliedResponse: {
|
|
6564
|
+
thread: components["schemas"]["CommentThreadSummary"];
|
|
6565
|
+
applied: components["schemas"]["SuggestionApplied"];
|
|
6566
|
+
};
|
|
6567
|
+
SuggestionApplyRequest: {
|
|
6568
|
+
claimToken: string;
|
|
6569
|
+
expectedRevision: string;
|
|
6570
|
+
};
|
|
6545
6571
|
/** @description Server-enforced collaboration size limits, published so clients can pre-check. */
|
|
6546
6572
|
CollabSyncLimits: {
|
|
6547
6573
|
maxUpdateBytes: number;
|
|
@@ -6712,6 +6738,17 @@ export interface components {
|
|
|
6712
6738
|
createdAt: string;
|
|
6713
6739
|
updatedAt: string;
|
|
6714
6740
|
};
|
|
6741
|
+
/** @description What the accept-apply did to the document: whether there was a CRDT to write into at all, and the canonical edit that was spliced. */
|
|
6742
|
+
SuggestionApplied: {
|
|
6743
|
+
bridged: boolean;
|
|
6744
|
+
edit: components["schemas"]["SuggestionPlannedEdit"];
|
|
6745
|
+
};
|
|
6746
|
+
/** @description The canonical mutation derived from the stored payload: splice out deleteLength UTF-16 code units at `at` and insert `insert`. */
|
|
6747
|
+
SuggestionPlannedEdit: {
|
|
6748
|
+
at: number;
|
|
6749
|
+
deleteLength: number;
|
|
6750
|
+
insert: string;
|
|
6751
|
+
};
|
|
6715
6752
|
};
|
|
6716
6753
|
responses: never;
|
|
6717
6754
|
parameters: never;
|
|
@@ -8542,7 +8579,7 @@ export interface operations {
|
|
|
8542
8579
|
query?: {
|
|
8543
8580
|
cursor?: string;
|
|
8544
8581
|
limit?: number;
|
|
8545
|
-
kind?: "integration.connect" | "integration.disconnect" | "profile.update" | "slack.send" | "data.ingest" | "data.scope" | "system.cleanup" | "member.changeManager" | "companyMd.ingest";
|
|
8582
|
+
kind?: "integration.connect" | "integration.disconnect" | "profile.update" | "slack.send" | "data.ingest" | "data.scope" | "system.cleanup" | "member.changeManager" | "companyMd.ingest" | "companyMd.commentPass" | "semantic.transform" | "companyMd.importContextDoc";
|
|
8546
8583
|
targetType?: string;
|
|
8547
8584
|
periodStart?: string;
|
|
8548
8585
|
periodEnd?: string;
|
|
@@ -8677,7 +8714,7 @@ export interface operations {
|
|
|
8677
8714
|
query?: {
|
|
8678
8715
|
limit?: number;
|
|
8679
8716
|
offset?: number;
|
|
8680
|
-
kind?: "integration.connect" | "integration.disconnect" | "profile.update" | "slack.send" | "data.ingest" | "data.scope" | "system.cleanup" | "member.changeManager" | "companyMd.ingest";
|
|
8717
|
+
kind?: "integration.connect" | "integration.disconnect" | "profile.update" | "slack.send" | "data.ingest" | "data.scope" | "system.cleanup" | "member.changeManager" | "companyMd.ingest" | "companyMd.commentPass" | "semantic.transform" | "companyMd.importContextDoc";
|
|
8681
8718
|
kindPrefix?: string;
|
|
8682
8719
|
};
|
|
8683
8720
|
header?: never;
|
|
@@ -12979,6 +13016,53 @@ export interface operations {
|
|
|
12979
13016
|
};
|
|
12980
13017
|
};
|
|
12981
13018
|
};
|
|
13019
|
+
acceptSuggestionApply: {
|
|
13020
|
+
parameters: {
|
|
13021
|
+
query?: never;
|
|
13022
|
+
header?: never;
|
|
13023
|
+
path: {
|
|
13024
|
+
threadId: string;
|
|
13025
|
+
};
|
|
13026
|
+
cookie?: never;
|
|
13027
|
+
};
|
|
13028
|
+
requestBody: {
|
|
13029
|
+
content: {
|
|
13030
|
+
"application/json": components["schemas"]["SuggestionApplyRequest"];
|
|
13031
|
+
};
|
|
13032
|
+
};
|
|
13033
|
+
responses: {
|
|
13034
|
+
/** @description Suggestion applied and accepted: the canonical edit landed in the document and the thread was resolved on the SAME transaction */
|
|
13035
|
+
200: {
|
|
13036
|
+
headers: {
|
|
13037
|
+
[name: string]: unknown;
|
|
13038
|
+
};
|
|
13039
|
+
content: {
|
|
13040
|
+
"application/json": components["schemas"]["SuggestionAcceptAppliedResponse"];
|
|
13041
|
+
};
|
|
13042
|
+
};
|
|
13043
|
+
/** @description Malformed claim token or expectedRevision */
|
|
13044
|
+
400: {
|
|
13045
|
+
headers: {
|
|
13046
|
+
[name: string]: unknown;
|
|
13047
|
+
};
|
|
13048
|
+
content?: never;
|
|
13049
|
+
};
|
|
13050
|
+
/** @description Thread not found, or the caller may not read its subject */
|
|
13051
|
+
404: {
|
|
13052
|
+
headers: {
|
|
13053
|
+
[name: string]: unknown;
|
|
13054
|
+
};
|
|
13055
|
+
content?: never;
|
|
13056
|
+
};
|
|
13057
|
+
/** @description Terminal, never retry: not a suggestion thread, the token does not match a live claim, the document moved past expectedRevision, the anchor is orphaned or no longer reads, or the payload resolves to a no-op — re-read the document and decide again */
|
|
13058
|
+
409: {
|
|
13059
|
+
headers: {
|
|
13060
|
+
[name: string]: unknown;
|
|
13061
|
+
};
|
|
13062
|
+
content?: never;
|
|
13063
|
+
};
|
|
13064
|
+
};
|
|
13065
|
+
};
|
|
12982
13066
|
releaseSuggestionClaim: {
|
|
12983
13067
|
parameters: {
|
|
12984
13068
|
query?: never;
|
package/src/comments/README.md
CHANGED
|
@@ -2,26 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
## Purpose
|
|
4
4
|
|
|
5
|
-
The published **read** vocabulary of the comment surface
|
|
5
|
+
The published **read** vocabulary of the comment surface — where a thread hangs
|
|
6
6
|
(`CommentAnchorSchema`), what a thread and its comments look like on the wire
|
|
7
7
|
(`CommentThreadSchema`, `CommentSchema`, `CommentMentionSchema`), and who a
|
|
8
|
-
comment may name (`MentionableResponseSchema`)
|
|
8
|
+
comment may name (`MentionableResponseSchema`) — together with the one published
|
|
9
|
+
INTERPRETATION of an anchor, in both directions: reading one against a document
|
|
10
|
+
(`resolveAnchorFromText`, `anchorStillReads`, `plannedEdit`), composing one from
|
|
11
|
+
a range (`createTextAnchorFromRange`, `createTextInsertionAnchorAt`), the size
|
|
12
|
+
bounds both halves share (`./anchor-constants`), and the receipt that proves an
|
|
13
|
+
accepted suggestion's edit already landed (`SUGGESTION_RECEIPTS_MAP`).
|
|
9
14
|
|
|
10
15
|
This exists as a published contract rather than an app-local type because two
|
|
11
16
|
independent clients have to agree about it without a server in the middle to
|
|
12
|
-
arbitrate — see the first
|
|
17
|
+
arbitrate — see the first two invariants. Publishing the SHAPE bounded that
|
|
18
|
+
risk; publishing the interpretation closes it (ADR-CONTRACTS-127), because a
|
|
19
|
+
second implementation is now an IMPORT rather than a second description.
|
|
20
|
+
Interpretation is pure, dependency-free derivation of published vocabulary, so
|
|
21
|
+
it stays inside the vocabulary-guard's rules — request bodies still do not.
|
|
13
22
|
|
|
14
23
|
## Invariants
|
|
15
24
|
|
|
16
|
-
- **
|
|
17
|
-
|
|
18
|
-
drift in it.** The route boundary validates the anchor's SHAPE and stores the
|
|
25
|
+
- **STORAGE IS STILL A PASSTHROUGH, and it still cannot catch drift in an
|
|
26
|
+
anchor.** The route boundary validates the anchor's SHAPE and stores the
|
|
19
27
|
result as opaque jsonb; the service, the database and every projection treat
|
|
20
28
|
it as bytes and interpret nothing. So if the writing client and the resolving
|
|
21
29
|
client disagree about what these fields mean, the write succeeds, the read
|
|
22
30
|
succeeds, and the comment silently lands on the wrong text. There is no
|
|
23
31
|
server-side tripwire, and there cannot be one — which is precisely why the
|
|
24
|
-
shape is published here instead of being declared twice.
|
|
32
|
+
shape is published here instead of being declared twice. ADR-CONTRACTS-127
|
|
33
|
+
left this half exactly as it was.
|
|
34
|
+
- **INTERPRETATION IS NO LONGER PER-CONSUMER — that is the half that did
|
|
35
|
+
change, and both halves are true at once.** `resolveAnchorFromText`,
|
|
36
|
+
`anchorStillReads`, `plannedEdit`, `createTextAnchorFromRange` and
|
|
37
|
+
`createTextInsertionAnchorAt` are the ONE published reading of those bytes
|
|
38
|
+
(ADR-CONTRACTS-127), so the backend becoming an eligible applier and a comment
|
|
39
|
+
pass running server-side are imports rather than a third and fourth
|
|
40
|
+
description of the same rules. This does NOT make the server an arbiter: the
|
|
41
|
+
anchor column is still opaque jsonb, still uninterpreted by the database and
|
|
42
|
+
by every projection, and the boundary still validates shape only. A reader who
|
|
43
|
+
takes either half alone gets the model wrong — one way they expect the server
|
|
44
|
+
to catch a bad anchor, the other way they write a second resolver.
|
|
25
45
|
- **`v` is the evolution hatch, and it is the only thing that bounds that
|
|
26
46
|
risk.** A client that meets an anchor it cannot resolve sees an unfamiliar `v`
|
|
27
47
|
and degrades on purpose instead of guessing at fields it half-recognises. New
|
|
@@ -43,15 +63,102 @@ arbitrate — see the first invariant.
|
|
|
43
63
|
silently. Instead the point carries an optional `relPos` (one
|
|
44
64
|
`Y.RelativePosition` at the caret) plus bilateral `leftContext`/`rightContext`
|
|
45
65
|
(each max 64 UTF-16 code units, required-but-may-be-empty at document edges).
|
|
46
|
-
Resolution
|
|
47
|
-
|
|
48
|
-
|
|
66
|
+
Resolution: rung 1 decodes `relPos` and verifies BOTH contexts around the
|
|
67
|
+
decoded point (stale-not-correct, like the text anchor's quote check) and
|
|
68
|
+
stays with the consumer, since decoding needs `yjs` and a live replica; rung 2
|
|
69
|
+
is published here as `resolveAnchorFromText` and searches for the UNIQUE
|
|
49
70
|
`leftContext`+`rightContext` seam — any ambiguity resolves orphaned, never a
|
|
50
71
|
guess; rung 3 orphaned. This variant ships contracts-first: its corpus cases
|
|
51
72
|
were added HERE with the deliberate same-diff digest re-pin ADR-CONTRACTS-116
|
|
52
73
|
allows (PRD-00933), and the backend boundary adopts both in PRD-00934 — until
|
|
53
74
|
then the upstream diff shows expected forward drift, and `anchor-corpus:sync`
|
|
54
75
|
must not be run (it would erase the new cases).
|
|
76
|
+
- **AMBIGUITY RESOLVES ORPHANED, AT BOTH LEVELS, AND NEVER BINDS TO THE FIRST
|
|
77
|
+
OCCURRENCE.** For a `text` anchor, `resolveAnchorFromText` searches the
|
|
78
|
+
context-qualified `prefix`+`quote`+`suffix` first, and TWO occurrences of that
|
|
79
|
+
string mean the context FAILED to identify a candidate — so the answer is
|
|
80
|
+
`orphaned`, and it does not then fall back to the bare quote, which would only
|
|
81
|
+
re-ask a question already known to be ambiguous. Absence is not ambiguity: an
|
|
82
|
+
ABSENT context does fall through to the bare `quote`, where a repeated quote is
|
|
83
|
+
orphaned in its own right. A repeated insertion seam gets the same answer for
|
|
84
|
+
the same reason, and an EMPTY seam is refused on a non-empty document rather
|
|
85
|
+
than treated as matching everywhere — an empty needle is a fact about the
|
|
86
|
+
search, not evidence about the point. (The one exception is the empty
|
|
87
|
+
document, where 0 is the only point there is.)
|
|
88
|
+
Under-anchoring is the deliberate error direction — a comment shown as
|
|
89
|
+
unanchored is legible and recoverable, while a comment shown against the wrong
|
|
90
|
+
sentence reads as a considered objection to a paragraph its author never saw.
|
|
91
|
+
Nothing outside this package enforces the rule: a consumer composing its own
|
|
92
|
+
rung 1 can resolve ambiguously THERE and never reach the published ladder's
|
|
93
|
+
refusal, so this is written down rather than typed (ADR-CONTRACTS-127).
|
|
94
|
+
- **The applier must not trust the finder.** `anchorStillReads` is a SECOND
|
|
95
|
+
assertion, independent of `resolveAnchorFromText` by construction: it reads the
|
|
96
|
+
anchor's own stored evidence against the live text and takes the resolution
|
|
97
|
+
only as a position. That a rung already checked something like it does not make
|
|
98
|
+
it redundant — the rung answers "where does this land", the assertion answers
|
|
99
|
+
"is what lands there still the thing", and a consumer composing the Yjs rung on
|
|
100
|
+
top supplies a position this package never computed. A `document` anchor
|
|
101
|
+
answers `false`: it names no characters, so it can vouch for no range.
|
|
102
|
+
- **A no-op is a refusal, not a cheap success.** `plannedEdit` returns `null` for
|
|
103
|
+
an `insert`/`replace` carrying no text and for a `delete`/`replace` over a
|
|
104
|
+
collapsed range. Each of those would otherwise run a transaction that writes a
|
|
105
|
+
receipt and changes not one character — the thread reported as `accepted` over
|
|
106
|
+
a document that never received the proposal. Both shapes mean the payload and
|
|
107
|
+
the anchor disagree about what kind of edit this is, and settling that
|
|
108
|
+
disagreement is not this package's to do.
|
|
109
|
+
- **The receipt is the durable proof that the edit ALREADY LANDED, and its KEY
|
|
110
|
+
is the contract.** Acceptance is apply-then-record, so there is one window —
|
|
111
|
+
between the transaction and the acknowledgement — in which the document has
|
|
112
|
+
changed and the thread still says `open`. `SUGGESTION_RECEIPTS_MAP` names the
|
|
113
|
+
root map the receipt is written into, in the SAME transaction as the edit, so
|
|
114
|
+
either both survived or neither did. A receipt written under any other name
|
|
115
|
+
lands in a map nobody reads while the reader watches a map nobody writes:
|
|
116
|
+
nothing throws, and the accept control stays live over an edit already in the
|
|
117
|
+
body. The name is spelled once, here, exactly as `COMPANY_MD_COLLAB_TEXT_KEY`
|
|
118
|
+
is for the body text.
|
|
119
|
+
- **The receipt lives BESIDE the body, never inside it, and ABSENCE IS NOT
|
|
120
|
+
PROOF.** It is a root map on the same document as the text, not a span in the
|
|
121
|
+
text — it must never reach the markdown source, an export, or the content
|
|
122
|
+
hash. And a reader holding no replica holds no receipts, so an empty map means
|
|
123
|
+
"no receipt is KNOWN HERE", never "no receipt exists": every consumer acts on
|
|
124
|
+
PRESENCE only. `readSuggestionReceipt` drops a malformed row to `null` rather
|
|
125
|
+
than surfacing a partial one precisely because that lands in the state every
|
|
126
|
+
consumer already has to handle correctly.
|
|
127
|
+
- **The receipt row is STRICT and carries no `v`, unlike the anchor and the
|
|
128
|
+
suggestion.** Those two are negotiated payloads that must grow while old
|
|
129
|
+
readers keep parsing. The receipt is three fields the shipped applier already
|
|
130
|
+
writes, promoted here rather than redesigned — a required `v` would make the
|
|
131
|
+
published parser reject every receipt in existence. A fourth fact arrives as a
|
|
132
|
+
new key or a versioned successor schema, never by widening this row.
|
|
133
|
+
- **CAPTURE AND ACCEPT ARE DIFFERENT NUMBERS, and collapsing them is a SILENT
|
|
134
|
+
REJECTION.** `ANCHOR_CONTEXT_CHARS` (32) is how much context a CONSTRUCTOR
|
|
135
|
+
captures either side of a range; `ANCHOR_AFFIX_MAX_CHARS` (64) is the largest
|
|
136
|
+
affix `CommentAnchorSchema` will ACCEPT. The gap is the point, not an
|
|
137
|
+
oversight: the capture width is a write-side choice that may change, while the
|
|
138
|
+
accept bound is a promise made to every anchor already in storage — durable
|
|
139
|
+
jsonb the server never re-validates. Narrowing accept to capture refuses
|
|
140
|
+
anchors a client with a wider capture already wrote; widening capture past
|
|
141
|
+
accept composes anchors the boundary then refuses, losing a comment AFTER it
|
|
142
|
+
was written. Both numbers now have exactly one declaration each, in
|
|
143
|
+
`./anchor-constants`, which is what keeps a schema change and a constructor
|
|
144
|
+
change from drifting apart — but nothing mechanical stops the two from being
|
|
145
|
+
set equal, so the distinction lives here.
|
|
146
|
+
- **Composing an anchor refuses rather than truncates, for the same reason
|
|
147
|
+
resolving it refuses rather than guesses.** `createTextAnchorFromRange` and
|
|
148
|
+
`createTextInsertionAnchorAt` return `null` for a non-integer or out-of-order
|
|
149
|
+
range, a range outside the text, an EMPTY range (the zero-width case has its
|
|
150
|
+
own constructor) and a quote past `QUOTE_MAX_CHARS`. A truncated quote yields
|
|
151
|
+
an anchor that parses, passes the boundary and resolves — onto a range the
|
|
152
|
+
caller never selected. Both validate the object they built through
|
|
153
|
+
`CommentAnchorSchema` before returning it, so the only anchors this package
|
|
154
|
+
emits are anchors the route boundary accepts: an anchor rejected there is a
|
|
155
|
+
comment lost AFTER it was composed.
|
|
156
|
+
- **The relative-position fields are never produced here, and their absence is
|
|
157
|
+
a fact, not an omission.** Encoding a `Y.RelativePosition` needs `yjs`, which
|
|
158
|
+
the vocabulary-guard forbids this package from importing, and it needs a live
|
|
159
|
+
replica besides. A consumer holding a `Y.Text` merges `relStart`/`relEnd` (or
|
|
160
|
+
`relPos`) onto the returned anchor itself — the same seam `./resolve` holds,
|
|
161
|
+
where a consumer composes rung 1 on top of the published text-only ladder.
|
|
55
162
|
- **A comment's `body` is null exactly when `deletedAt` is set, and such a
|
|
56
163
|
comment carries no mentions.** A soft-deleted comment is a REDACTED
|
|
57
164
|
PROJECTION, not a removed row — the tombstone keeps its position in the
|
|
@@ -93,32 +200,68 @@ arbitrate — see the first invariant.
|
|
|
93
200
|
|
|
94
201
|
## Public API
|
|
95
202
|
|
|
96
|
-
| Export | Description
|
|
97
|
-
| --------------------------------- |
|
|
98
|
-
| `CommentAnchorSchema` | `{document,v:1}` \| `{text,v:1,…}` \| `{text-insertion,v:1,…}`, strict
|
|
99
|
-
| `COMMENT_ANCHOR_TYPES` | The anchor discriminants, as a vocabulary tuple
|
|
100
|
-
| `CommentAnchorTypeSchema` | Zod mirror of `COMMENT_ANCHOR_TYPES` — what `anchorType` is checked against
|
|
101
|
-
| `COMMENT_THREAD_STATUSES` | `open` \| `resolved` — there is no third state
|
|
102
|
-
| `CommentThreadStatusSchema` | Zod mirror of `COMMENT_THREAD_STATUSES`
|
|
103
|
-
| `COMMENT_SUBJECT_TYPES` | What a thread can hang off — closed, sized to what admits comments
|
|
104
|
-
| `CommentSubjectTypeSchema` | Zod mirror of `COMMENT_SUBJECT_TYPES`
|
|
105
|
-
| `CommentMentionSchema` | One mention: user id, nullable UTF-16 range, read-time display name
|
|
106
|
-
| `CommentSchema` | One comment; `body` null iff soft-deleted (infers `CommentProjection`)
|
|
107
|
-
| `CommentThreadSummarySchema` | A thread without its comments — the resolve/reopen response
|
|
108
|
-
| `CommentThreadSchema` | A thread with all its comments, oldest-first, tombstones included
|
|
109
|
-
| `CommentThreadListResponseSchema` | `GET /api/comments`
|
|
110
|
-
| `MentionableCandidateSchema` | One offerable mention target — no email, no access level
|
|
111
|
-
| `MentionableResponseSchema` | `GET /api/company-md/docs/{id}/mentionable`
|
|
112
|
-
| `COMMENT_THREAD_KINDS` | `comment` \| `suggestion` — a suggestion IS a comment thread
|
|
113
|
-
| `SUGGESTION_OPS` | `insert` \| `delete` \| `replace` — what a suggestion proposes
|
|
114
|
-
| `SUGGESTION_STATUSES` | `open` \| `accepted` \| `rejected` — the SECOND status level, terminal
|
|
115
|
-
| `SuggestionSchema` | The versioned suggestion payload (`v:1`), strict, `insertedText` optional
|
|
203
|
+
| Export | Description |
|
|
204
|
+
| --------------------------------- | ------------------------------------------------------------------------------ |
|
|
205
|
+
| `CommentAnchorSchema` | `{document,v:1}` \| `{text,v:1,…}` \| `{text-insertion,v:1,…}`, strict |
|
|
206
|
+
| `COMMENT_ANCHOR_TYPES` | The anchor discriminants, as a vocabulary tuple |
|
|
207
|
+
| `CommentAnchorTypeSchema` | Zod mirror of `COMMENT_ANCHOR_TYPES` — what `anchorType` is checked against |
|
|
208
|
+
| `COMMENT_THREAD_STATUSES` | `open` \| `resolved` — there is no third state |
|
|
209
|
+
| `CommentThreadStatusSchema` | Zod mirror of `COMMENT_THREAD_STATUSES` |
|
|
210
|
+
| `COMMENT_SUBJECT_TYPES` | What a thread can hang off — closed, sized to what admits comments |
|
|
211
|
+
| `CommentSubjectTypeSchema` | Zod mirror of `COMMENT_SUBJECT_TYPES` |
|
|
212
|
+
| `CommentMentionSchema` | One mention: user id, nullable UTF-16 range, read-time display name |
|
|
213
|
+
| `CommentSchema` | One comment; `body` null iff soft-deleted (infers `CommentProjection`) |
|
|
214
|
+
| `CommentThreadSummarySchema` | A thread without its comments — the resolve/reopen response |
|
|
215
|
+
| `CommentThreadSchema` | A thread with all its comments, oldest-first, tombstones included |
|
|
216
|
+
| `CommentThreadListResponseSchema` | `GET /api/comments` |
|
|
217
|
+
| `MentionableCandidateSchema` | One offerable mention target — no email, no access level |
|
|
218
|
+
| `MentionableResponseSchema` | `GET /api/company-md/docs/{id}/mentionable` |
|
|
219
|
+
| `COMMENT_THREAD_KINDS` | `comment` \| `suggestion` — a suggestion IS a comment thread |
|
|
220
|
+
| `SUGGESTION_OPS` | `insert` \| `delete` \| `replace` — what a suggestion proposes |
|
|
221
|
+
| `SUGGESTION_STATUSES` | `open` \| `accepted` \| `rejected` — the SECOND status level, terminal |
|
|
222
|
+
| `SuggestionSchema` | The versioned suggestion payload (`v:1`), strict, `insertedText` optional |
|
|
223
|
+
| `ANCHOR_CONTEXT_CHARS` | `32` — how much context a CONSTRUCTOR captures either side of a range |
|
|
224
|
+
| `ANCHOR_AFFIX_MAX_CHARS` | `64` — the largest affix the schema ACCEPTS (prefix/suffix/left/right) |
|
|
225
|
+
| `QUOTE_MAX_CHARS` | `2000` — the largest `quote` a text anchor may carry |
|
|
226
|
+
| `RELATIVE_POSITION_MAX_CHARS` | `512` — cap on one encoded `Y.RelativePosition`, in base64 chars |
|
|
227
|
+
| `resolveAnchorFromText` | Anchor + text → `AnchorResolution`. Total, pure, text-only; never throws |
|
|
228
|
+
| `AnchorResolution` | `resolved{start,end,rung}` \| `document` \| `orphaned` — three outcomes |
|
|
229
|
+
| `findSoleOccurrence` | `absent` \| `unique{index}` \| `ambiguous`; overlaps count, empty is ambiguous |
|
|
230
|
+
| `Occurrence` | The result of `findSoleOccurrence` |
|
|
231
|
+
| `contextsMatchAt` | Do these contexts still flank this point — with the two length guards |
|
|
232
|
+
| `contextBefore` / `contextAfter` | Capture `ANCHOR_CONTEXT_CHARS` of context, never splitting a surrogate pair |
|
|
233
|
+
| `anchorStillReads` | The SECOND assertion: does the resolved place still hold what the anchor says |
|
|
234
|
+
| `plannedEdit` | Suggestion + resolution → the one mutation, or `null` when it would do nothing |
|
|
235
|
+
| `PlannedEdit` | `{at, deleteLength, insert}` — delete then insert, at one point |
|
|
236
|
+
| `createTextAnchorFromRange` | Range → validated text anchor, or `null`; refuses rather than truncates |
|
|
237
|
+
| `createTextInsertionAnchorAt` | Point → validated zero-width anchor, or `null`; no relative position |
|
|
238
|
+
| `TextRange` | `{text, start, end}` — the offsets travel WITH the string they index |
|
|
239
|
+
| `SUGGESTION_RECEIPTS_MAP` | `suggestionReceipts` — the root map key, keyed within by thread id |
|
|
240
|
+
| `SuggestionReceiptSchema` | `{claimToken, appliedAt, byUserId}`, strict; no `v`, and that is deliberate |
|
|
241
|
+
| `SuggestionReceipt` | One applied suggestion's durable proof that its edit landed in the body |
|
|
242
|
+
| `readSuggestionReceipt` | Unknown → receipt or `null`; a malformed row is dropped, never surfaced |
|
|
116
243
|
|
|
117
244
|
## Dependencies
|
|
118
245
|
|
|
119
246
|
- `zod` — schemas are canonical, types are inferred.
|
|
120
|
-
- `./anchor` ← `./schemas`; `./
|
|
121
|
-
|
|
247
|
+
- `./anchor-constants` ← `./anchor` ← `./schemas`; `./anchor-constants` is a
|
|
248
|
+
leaf with no imports at all (four numbers and their rationale), so the bounds
|
|
249
|
+
can be read by a constructor without pulling in the schema. `./resolve` sits
|
|
250
|
+
beside `./schemas` on that chain — it imports `./anchor-constants` for the
|
|
251
|
+
capture width and the anchor union as a TYPE only, so nothing about the
|
|
252
|
+
interpretation can drift from the shape it interprets. `./verify` sits one
|
|
253
|
+
step further out, importing `contextsMatchAt` from `./resolve` at runtime
|
|
254
|
+
rather than re-deriving it — a private copy that forgot a length guard would
|
|
255
|
+
compare against an unrelated substring and answer `true`, which is the one
|
|
256
|
+
wrong answer that mutates the document. `./create` sits beside `./verify`,
|
|
257
|
+
importing `contextBefore`/`contextAfter` from `./resolve` at runtime and
|
|
258
|
+
`CommentAnchorSchema` from `./anchor` — the construction half must capture
|
|
259
|
+
exactly what the resolution half searches for, and validate against exactly
|
|
260
|
+
the schema the boundary applies, so neither is re-derived. `./suggestion` is
|
|
261
|
+
likewise a leaf (zod only) so `./schemas` can import the thread-kind
|
|
262
|
+
projection from it without a cycle. `./receipt` is a leaf for the same
|
|
263
|
+
reason — zod only, no reach for the anchor or the ladder — so anything in this
|
|
264
|
+
directory can import it later without one.
|
|
122
265
|
- Nothing outside this directory is imported: the
|
|
123
266
|
comment vocabulary binds to the existing `commenter` band of
|
|
124
267
|
`../permissions`'s `AccessLevel` and introduces no access level of its own, so
|
|
@@ -33,6 +33,49 @@ text-anchor fields` name the two failure modes the strict variants exist for,
|
|
|
33
33
|
anything the typechecker reaches would fail the build. Reading it keeps the
|
|
34
34
|
fixture inert regardless of which file loads it. `node:fs` is fine in here —
|
|
35
35
|
the vocabulary-guard skips `__tests__/`.
|
|
36
|
+
- **Every `orphaned` expectation in `resolve.test.ts` is a case where a
|
|
37
|
+
FIRST-MATCH resolver returns a well-formed range.** That is the whole point of
|
|
38
|
+
those fixtures: the plausible wrong implementation never throws and never
|
|
39
|
+
looks broken — it produces a confident highlight on a paragraph the comment's
|
|
40
|
+
author never saw. A suite that only asserted the happy path would sit green
|
|
41
|
+
over it, so the refusals are the load-bearing half.
|
|
42
|
+
- **Every `toBeNull()` expectation in `verify.test.ts` is a case where a
|
|
43
|
+
no-op-tolerant `plannedEdit` returns a well-formed edit.** `{ at,
|
|
44
|
+
deleteLength: 0, insert: "" }` is a valid mutation that applies without error
|
|
45
|
+
and changes not one character; its symptom is a thread reported as `accepted`
|
|
46
|
+
over a document that never received the proposal. Same shape of argument as
|
|
47
|
+
the `orphaned` expectations above: the refusals are the load-bearing half.
|
|
48
|
+
- **`verify.test.ts` builds the `document`-anchor resolution BY HAND, and that
|
|
49
|
+
is not a smell.** The shipped ladder answers `{ status: "document" }` for that
|
|
50
|
+
variant and never a `resolved`, so the case is unreachable through
|
|
51
|
+
`resolveAnchorFromText` — but a consumer's own Yjs rung can hand one in, and
|
|
52
|
+
the refusal is what makes that harmless.
|
|
53
|
+
- **`create.test.ts` asserts against the SHIPPED resolver, not against the
|
|
54
|
+
anchor's fields.** A suite that only checked `prefix`/`quote`/`suffix` would
|
|
55
|
+
sit green over a capture width that disagreed with what the resolver searches
|
|
56
|
+
for — the two halves have to agree about the same geometry, and the round-trip
|
|
57
|
+
is the only assertion that says so. Its `toBeNull()` cases are the same shape
|
|
58
|
+
of argument as the `orphaned` ones: the truncating constructor returns a
|
|
59
|
+
well-formed anchor that parses, passes the boundary and resolves onto a range
|
|
60
|
+
the caller never selected.
|
|
61
|
+
- **The surrogate round-trip is built so BOTH context windows land inside an
|
|
62
|
+
astral character.** The prefix window opens on the low half of one emoji and
|
|
63
|
+
the suffix window closes on the high half of another, because a blind slice
|
|
64
|
+
only emits a lone surrogate when the boundary actually falls there — a fixture
|
|
65
|
+
with emoji merely present would pass under the broken implementation.
|
|
66
|
+
- **Every `toBeNull()` in `receipt.test.ts` is a row the PASS-THROUGH parser
|
|
67
|
+
hands back as an object.** `(v) => v as SuggestionReceipt` compiles, is green
|
|
68
|
+
on every well-formed row and never throws; it fails only when a peer writes
|
|
69
|
+
something else into the map, and then a row whose `claimToken` is `undefined`
|
|
70
|
+
reads as "this edit already landed" — the accept control is withdrawn from a
|
|
71
|
+
live suggestion with no way back. The `NaN`/`Infinity` cases are not
|
|
72
|
+
theoretical: a CRDT map replicates raw JavaScript values with no schema of its
|
|
73
|
+
own, so a peer can put a literal `NaN` in `appliedAt`.
|
|
74
|
+
- **`SUGGESTION_RECEIPTS_MAP` is tested by restating its wire literal.** It
|
|
75
|
+
names a root map, so a rename is silent on both ends — the writer's receipt
|
|
76
|
+
lands where nobody reads, the reader watches where nobody writes. Asserting
|
|
77
|
+
`toBe("suggestionReceipts")` makes a rename a red test rather than a second
|
|
78
|
+
name. Same convention as `COMPANY_MD_COLLAB_TEXT_KEY` in `../../org`.
|
|
36
79
|
- **Negative tests mutate ONE field of a well-formed factory result.** A
|
|
37
80
|
hand-built broken object can pass for the wrong reason: it fails because of
|
|
38
81
|
the field nobody was testing.
|
|
@@ -46,4 +89,20 @@ None — test-only.
|
|
|
46
89
|
- `vitest` — `describe` / `it` / `expect`.
|
|
47
90
|
- `node:fs`, `node:path`, `node:url` — to read the corpus and its pin from disk.
|
|
48
91
|
- `node:crypto` — to re-derive the corpus digest.
|
|
49
|
-
- `../anchor`, `../schemas` — the schemas
|
|
92
|
+
- `../anchor`, `../anchor-constants`, `../schemas` — the schemas and bounds
|
|
93
|
+
under test.
|
|
94
|
+
- `../resolve` — the published resolver. `resolve.test.ts` drives it over the
|
|
95
|
+
SAME corpus rows `anchor-corpus.test.ts` re-derives by hand, which is what
|
|
96
|
+
makes that hand-rolled seam scan a cross-check of the shipped implementation
|
|
97
|
+
rather than of itself.
|
|
98
|
+
- `../suggestion`, `../verify` — the canonical payload and the two total
|
|
99
|
+
functions the acceptance path runs before it writes. `verify.test.ts` takes
|
|
100
|
+
its resolutions from the SHIPPED resolver wherever the ladder can produce one,
|
|
101
|
+
so the geometry under test is the geometry a real applier is handed.
|
|
102
|
+
- `../create` — the construction half. `create.test.ts` feeds every constructed
|
|
103
|
+
anchor back through `../resolve`, so it is a cross-check of the two published
|
|
104
|
+
halves against each other rather than of either against a restatement of
|
|
105
|
+
itself.
|
|
106
|
+
- `../receipt` — the application receipt's key, schema and parser.
|
|
107
|
+
`receipt.test.ts` asserts the parser and the schema agree on the same row, so
|
|
108
|
+
a hand-rolled second set of checks beside the schema cannot drift from it.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The published anchor size bounds, and the proof that `CommentAnchorSchema`
|
|
3
|
+
* actually reads them.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS SUITE EXISTS. The bounds used to be inline literals in `../anchor`,
|
|
6
|
+
* duplicated by hand in the app's constructor. Naming them removes the
|
|
7
|
+
* duplication only if two things stay true, and neither is mechanically
|
|
8
|
+
* enforced: the VALUES must not have moved during the extraction, and the
|
|
9
|
+
* schema must be reading the named constant rather than a leftover literal
|
|
10
|
+
* that happens to agree with it today.
|
|
11
|
+
*
|
|
12
|
+
* The mirrored corpus in `./fixtures/comment-anchors.json` proves the 64
|
|
13
|
+
* affix bound from both sides (its `oversized-prefix`/`oversized-suffix` rows
|
|
14
|
+
* are 65 chars), but NOTHING anywhere exercised the quote bound at 2000 or a
|
|
15
|
+
* relative position at 512 — a typo in either would have shipped green. Each
|
|
16
|
+
* boundary case below is that missing tripwire: accept at exactly the bound,
|
|
17
|
+
* refuse one code unit past it, phrased in terms of the constant so the test
|
|
18
|
+
* moves with the value it is protecting rather than re-pinning it a third time.
|
|
19
|
+
*
|
|
20
|
+
* The bare value assertions are the other half: they are what catches a bound
|
|
21
|
+
* being CHANGED (as opposed to being read from the wrong place), which no
|
|
22
|
+
* schema-relative assertion can see.
|
|
23
|
+
*/
|
|
24
|
+
import { describe, expect, it } from "vitest";
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
ANCHOR_AFFIX_MAX_CHARS,
|
|
28
|
+
ANCHOR_CONTEXT_CHARS,
|
|
29
|
+
QUOTE_MAX_CHARS,
|
|
30
|
+
RELATIVE_POSITION_MAX_CHARS,
|
|
31
|
+
} from "../anchor-constants.js";
|
|
32
|
+
import { CommentAnchorSchema } from "../anchor.js";
|
|
33
|
+
|
|
34
|
+
/** A well-formed text anchor; override one field per boundary test. */
|
|
35
|
+
const makeTextAnchor = (over: Record<string, unknown> = {}) => ({
|
|
36
|
+
type: "text",
|
|
37
|
+
v: 1,
|
|
38
|
+
quote: "the quarterly goals",
|
|
39
|
+
prefix: "we restated ",
|
|
40
|
+
suffix: " last week",
|
|
41
|
+
...over,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** A well-formed insertion anchor; override one field per boundary test. */
|
|
45
|
+
const makeInsertionAnchor = (over: Record<string, unknown> = {}) => ({
|
|
46
|
+
type: "text-insertion",
|
|
47
|
+
v: 1,
|
|
48
|
+
leftContext: "the quarterly ",
|
|
49
|
+
rightContext: " goals",
|
|
50
|
+
...over,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("the published anchor size bounds", () => {
|
|
54
|
+
it("holds the values the wire contract was published with", () => {
|
|
55
|
+
// These are durable promises, not knobs: anchors already in storage were
|
|
56
|
+
// written against them, and the backend route boundary declares the same
|
|
57
|
+
// numbers independently. Changing one here is a wire change.
|
|
58
|
+
expect(ANCHOR_CONTEXT_CHARS).toBe(32);
|
|
59
|
+
expect(ANCHOR_AFFIX_MAX_CHARS).toBe(64);
|
|
60
|
+
expect(QUOTE_MAX_CHARS).toBe(2000);
|
|
61
|
+
expect(RELATIVE_POSITION_MAX_CHARS).toBe(512);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("keeps capture strictly narrower than accept", () => {
|
|
65
|
+
// The two "context" numbers are NOT a duplication waiting to be collapsed.
|
|
66
|
+
// A constructor captures ANCHOR_CONTEXT_CHARS; the schema accepts up to
|
|
67
|
+
// ANCHOR_AFFIX_MAX_CHARS, so an anchor written by a client with a wider
|
|
68
|
+
// capture still parses. Narrowing accept to capture would refuse durable
|
|
69
|
+
// jsonb the server never re-validates.
|
|
70
|
+
expect(ANCHOR_CONTEXT_CHARS).toBeLessThan(ANCHOR_AFFIX_MAX_CHARS);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("CommentAnchorSchema reads the published bounds", () => {
|
|
75
|
+
it("accepts a quote of exactly QUOTE_MAX_CHARS and refuses one more", () => {
|
|
76
|
+
// The quote is the durable fallback the passage is re-found by, so it is
|
|
77
|
+
// bounded generously — but bounded, or an anchor becomes a second copy of
|
|
78
|
+
// the document in a column nothing on the server reads.
|
|
79
|
+
const atBound = "q".repeat(QUOTE_MAX_CHARS);
|
|
80
|
+
expect(
|
|
81
|
+
CommentAnchorSchema.safeParse(makeTextAnchor({ quote: atBound })).success,
|
|
82
|
+
).toBe(true);
|
|
83
|
+
expect(
|
|
84
|
+
CommentAnchorSchema.safeParse(makeTextAnchor({ quote: `${atBound}q` }))
|
|
85
|
+
.success,
|
|
86
|
+
).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("refuses an empty quote", () => {
|
|
90
|
+
// `quote` is required AND non-empty: an anchor with no quote is unplaceable
|
|
91
|
+
// the first time the document is edited.
|
|
92
|
+
expect(
|
|
93
|
+
CommentAnchorSchema.safeParse(makeTextAnchor({ quote: "" })).success,
|
|
94
|
+
).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("bounds prefix and suffix at ANCHOR_AFFIX_MAX_CHARS", () => {
|
|
98
|
+
const atBound = "x".repeat(ANCHOR_AFFIX_MAX_CHARS);
|
|
99
|
+
expect(
|
|
100
|
+
CommentAnchorSchema.safeParse(
|
|
101
|
+
makeTextAnchor({ prefix: atBound, suffix: atBound }),
|
|
102
|
+
).success,
|
|
103
|
+
).toBe(true);
|
|
104
|
+
expect(
|
|
105
|
+
CommentAnchorSchema.safeParse(makeTextAnchor({ prefix: `${atBound}x` }))
|
|
106
|
+
.success,
|
|
107
|
+
).toBe(false);
|
|
108
|
+
expect(
|
|
109
|
+
CommentAnchorSchema.safeParse(makeTextAnchor({ suffix: `${atBound}x` }))
|
|
110
|
+
.success,
|
|
111
|
+
).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("bounds leftContext and rightContext at the SAME affix bound", () => {
|
|
115
|
+
// One constant, four fields: the insertion seam is context in the same
|
|
116
|
+
// sense prefix/suffix are, and a reader must not have to check whether the
|
|
117
|
+
// two variants drifted apart.
|
|
118
|
+
const atBound = "x".repeat(ANCHOR_AFFIX_MAX_CHARS);
|
|
119
|
+
expect(
|
|
120
|
+
CommentAnchorSchema.safeParse(
|
|
121
|
+
makeInsertionAnchor({ leftContext: atBound, rightContext: atBound }),
|
|
122
|
+
).success,
|
|
123
|
+
).toBe(true);
|
|
124
|
+
expect(
|
|
125
|
+
CommentAnchorSchema.safeParse(
|
|
126
|
+
makeInsertionAnchor({ rightContext: `${atBound}x` }),
|
|
127
|
+
).success,
|
|
128
|
+
).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("bounds every relative position at RELATIVE_POSITION_MAX_CHARS", () => {
|
|
132
|
+
// relStart/relEnd/relPos are opaque base64 the backend never decodes, so
|
|
133
|
+
// the length and the charset are the ONLY things anyone checks. Applied to
|
|
134
|
+
// all three fields because a position that is bounded on one path and
|
|
135
|
+
// unbounded on another is not bounded.
|
|
136
|
+
const atBound = "A".repeat(RELATIVE_POSITION_MAX_CHARS);
|
|
137
|
+
const overBound = `${atBound}A`;
|
|
138
|
+
|
|
139
|
+
expect(
|
|
140
|
+
CommentAnchorSchema.safeParse(
|
|
141
|
+
makeTextAnchor({ relStart: atBound, relEnd: atBound }),
|
|
142
|
+
).success,
|
|
143
|
+
).toBe(true);
|
|
144
|
+
expect(
|
|
145
|
+
CommentAnchorSchema.safeParse(makeTextAnchor({ relStart: overBound }))
|
|
146
|
+
.success,
|
|
147
|
+
).toBe(false);
|
|
148
|
+
expect(
|
|
149
|
+
CommentAnchorSchema.safeParse(makeTextAnchor({ relEnd: overBound }))
|
|
150
|
+
.success,
|
|
151
|
+
).toBe(false);
|
|
152
|
+
expect(
|
|
153
|
+
CommentAnchorSchema.safeParse(makeInsertionAnchor({ relPos: atBound }))
|
|
154
|
+
.success,
|
|
155
|
+
).toBe(true);
|
|
156
|
+
expect(
|
|
157
|
+
CommentAnchorSchema.safeParse(makeInsertionAnchor({ relPos: overBound }))
|
|
158
|
+
.success,
|
|
159
|
+
).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
});
|