| 1 | import * as Result from "effect/Result"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | |
| 4 | import { |
| 5 | decodeItemContentJson, |
| 6 | decodeIterationChangesJson, |
| 7 | decodeIterationsJson, |
| 8 | decodePullRequestJson, |
| 9 | decodePullRequestListJson, |
| 10 | decodeThreadsJson, |
| 11 | decodeViewerJson, |
| 12 | } from "./azureDevOpsPullRequestJson.ts"; |
| 13 | |
| 14 | const REST_URL = |
| 15 | "https://dev.azure.com/acme/_apis/git/repositories/6f9c9b7f-0000-0000-0000-000000000000/pullRequests/42"; |
| 16 | |
| 17 | /** Shaped after Azure's `GitPullRequest`, trimmed to the fields that are read. */ |
| 18 | function pullRequest(overrides: Record<string, unknown> = {}): Record<string, unknown> { |
| 19 | return { |
| 20 | pullRequestId: 42, |
| 21 | title: "Add the change requests page", |
| 22 | description: "Ships the page.", |
| 23 | status: "active", |
| 24 | isDraft: false, |
| 25 | mergeStatus: "succeeded", |
| 26 | createdBy: { displayName: "Bilal Hassan", uniqueName: "bilal@acme.dev" }, |
| 27 | sourceRefName: "refs/heads/feat/page", |
| 28 | targetRefName: "refs/heads/main", |
| 29 | creationDate: "2026-07-01T00:00:00Z", |
| 30 | url: REST_URL, |
| 31 | repository: { name: "web", project: { name: "platform" } }, |
| 32 | ...overrides, |
| 33 | }; |
| 34 | } |
| 35 | |
| 36 | function expectSuccess<A>(result: Result.Result<A, unknown>): A { |
| 37 | expect(Result.isSuccess(result)).toBe(true); |
| 38 | if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); |
| 39 | return result.success; |
| 40 | } |
| 41 | |
| 42 | const asJson = (value: unknown) => JSON.stringify(value); |
| 43 | |
| 44 | describe("decodePullRequestListJson", () => { |
| 45 | it("reads a pull request as a change request", () => { |
| 46 | const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest()]))); |
| 47 | |
| 48 | expect(batch.items).toHaveLength(1); |
| 49 | expect(batch.items[0]).toMatchObject({ |
| 50 | number: 42, |
| 51 | title: "Add the change requests page", |
| 52 | // The login is an email, because that is what `az account show` reports to compare with. |
| 53 | author: { login: "bilal@acme.dev", name: "Bilal Hassan" }, |
| 54 | // Azure prefixes its refs, which no other host does. |
| 55 | headBranch: "feat/page", |
| 56 | baseBranch: "main", |
| 57 | state: "open", |
| 58 | isDraft: false, |
| 59 | mergeability: "mergeable", |
| 60 | }); |
| 61 | }); |
| 62 | |
| 63 | it("assembles a browser url when Azure reports no web link", () => { |
| 64 | const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest()]))); |
| 65 | |
| 66 | expect(batch.items[0]?.url).toBe("https://dev.azure.com/acme/platform/_git/web/pullrequest/42"); |
| 67 | }); |
| 68 | |
| 69 | it("prefers the web link Azure sends when asked for one", () => { |
| 70 | const batch = expectSuccess( |
| 71 | decodePullRequestListJson( |
| 72 | asJson([ |
| 73 | pullRequest({ |
| 74 | _links: { |
| 75 | web: { href: "https://dev.azure.com/acme/platform/_git/web/pullrequest/42" }, |
| 76 | }, |
| 77 | }), |
| 78 | ]), |
| 79 | ), |
| 80 | ); |
| 81 | |
| 82 | expect(batch.items[0]?.url).toBe("https://dev.azure.com/acme/platform/_git/web/pullrequest/42"); |
| 83 | }); |
| 84 | |
| 85 | it.each([ |
| 86 | ["active", "open"], |
| 87 | ["completed", "merged"], |
| 88 | ["abandoned", "closed"], |
| 89 | ["something new", "open"], |
| 90 | ])("reads the %s status as %s", (status, expected) => { |
| 91 | const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest({ status })]))); |
| 92 | |
| 93 | expect(batch.items[0]?.state).toBe(expected); |
| 94 | }); |
| 95 | |
| 96 | it.each([ |
| 97 | ["succeeded", "mergeable"], |
| 98 | ["conflicts", "conflicting"], |
| 99 | ["rejectedByPolicy", "conflicting"], |
| 100 | ["queued", "unknown"], |
| 101 | ["notSet", "unknown"], |
| 102 | ])("reads the %s merge status as %s", (mergeStatus, expected) => { |
| 103 | const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest({ mergeStatus })]))); |
| 104 | |
| 105 | expect(batch.items[0]?.mergeability).toBe(expected); |
| 106 | }); |
| 107 | |
| 108 | it("stands the closing time in for a last-touched time Azure does not keep", () => { |
| 109 | const batch = expectSuccess( |
| 110 | decodePullRequestListJson( |
| 111 | asJson([pullRequest({ status: "completed", closedDate: "2026-07-05T00:00:00Z" })]), |
| 112 | ), |
| 113 | ); |
| 114 | |
| 115 | expect(batch.items[0]).toMatchObject({ |
| 116 | createdAt: "2026-07-01T00:00:00Z", |
| 117 | updatedAt: "2026-07-05T00:00:00Z", |
| 118 | }); |
| 119 | }); |
| 120 | |
| 121 | it("skips a malformed row but still counts it, so paging does not stop early", () => { |
| 122 | const batch = expectSuccess( |
| 123 | decodePullRequestListJson(asJson([{ pullRequestId: "nope" }, pullRequest()])), |
| 124 | ); |
| 125 | |
| 126 | expect(batch.items).toHaveLength(1); |
| 127 | expect(batch.rawCount).toBe(2); |
| 128 | expect(batch.rawIndexes).toEqual([1]); |
| 129 | }); |
| 130 | }); |
| 131 | |
| 132 | describe("decodePullRequestJson", () => { |
| 133 | it("reads reviewers as review requests", () => { |
| 134 | const detail = expectSuccess( |
| 135 | decodePullRequestJson( |
| 136 | asJson( |
| 137 | pullRequest({ |
| 138 | reviewers: [{ displayName: "Julius", uniqueName: "julius@acme.dev", vote: 10 }], |
| 139 | }), |
| 140 | ), |
| 141 | ), |
| 142 | ); |
| 143 | |
| 144 | expect(detail?.reviewRequestLogins).toEqual(["julius@acme.dev"]); |
| 145 | expect(detail?.reviewers).toEqual([ |
| 146 | { login: "julius@acme.dev", name: "Julius", avatarUrl: null }, |
| 147 | ]); |
| 148 | }); |
| 149 | |
| 150 | it("reads auto-complete from whoever armed it, and its absence as nobody", () => { |
| 151 | const armed = expectSuccess( |
| 152 | decodePullRequestJson( |
| 153 | asJson(pullRequest({ autoCompleteSetBy: { displayName: "Bilal Hassan" } })), |
| 154 | ), |
| 155 | ); |
| 156 | expect(armed?.autoMergeEnabled).toBe(true); |
| 157 | |
| 158 | // Azure leaves the field out entirely rather than sending it empty, so its absence is the |
| 159 | // whole of what it says about auto-complete being off. |
| 160 | expect(expectSuccess(decodePullRequestJson(asJson(pullRequest())))?.autoMergeEnabled).toBe( |
| 161 | false, |
| 162 | ); |
| 163 | }); |
| 164 | |
| 165 | it("keeps the strategy stored with auto-complete", () => { |
| 166 | const armed = expectSuccess( |
| 167 | decodePullRequestJson( |
| 168 | asJson( |
| 169 | pullRequest({ |
| 170 | autoCompleteSetBy: { displayName: "Bilal Hassan" }, |
| 171 | completionOptions: { mergeStrategy: "squash" }, |
| 172 | }), |
| 173 | ), |
| 174 | ), |
| 175 | ); |
| 176 | |
| 177 | expect(armed).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" }); |
| 178 | |
| 179 | const unspecified = expectSuccess( |
| 180 | decodePullRequestJson( |
| 181 | asJson( |
| 182 | pullRequest({ |
| 183 | autoCompleteSetBy: { displayName: "Bilal Hassan" }, |
| 184 | completionOptions: { squashMerge: false }, |
| 185 | }), |
| 186 | ), |
| 187 | ), |
| 188 | ); |
| 189 | expect(unspecified?.autoMergeEnabled).toBe(true); |
| 190 | expect(unspecified?.autoMergeMethod).toBeUndefined(); |
| 191 | }); |
| 192 | |
| 193 | it("works out where the repository lives from what Azure returned", () => { |
| 194 | const detail = expectSuccess(decodePullRequestJson(asJson(pullRequest()))); |
| 195 | |
| 196 | expect(detail?.location).toEqual({ project: "platform", repository: "web" }); |
| 197 | }); |
| 198 | |
| 199 | it("reports no repository location when Azure said too little to name one", () => { |
| 200 | // A web link places the pull request, but with no repository named there is nothing to |
| 201 | // address the routes that read its files and its conversation. |
| 202 | const detail = expectSuccess( |
| 203 | decodePullRequestJson( |
| 204 | asJson( |
| 205 | pullRequest({ |
| 206 | url: null, |
| 207 | repository: null, |
| 208 | _links: { |
| 209 | web: { href: "https://dev.azure.com/acme/platform/_git/web/pullrequest/42" }, |
| 210 | }, |
| 211 | }), |
| 212 | ), |
| 213 | ), |
| 214 | ); |
| 215 | |
| 216 | expect(detail?.location).toBeNull(); |
| 217 | }); |
| 218 | |
| 219 | it("returns nothing when Azure gave no way to place the pull request at all", () => { |
| 220 | const detail = expectSuccess( |
| 221 | decodePullRequestJson(asJson(pullRequest({ url: null, repository: null }))), |
| 222 | ); |
| 223 | |
| 224 | expect(detail).toBeNull(); |
| 225 | }); |
| 226 | }); |
| 227 | |
| 228 | describe("decodeViewerJson", () => { |
| 229 | it("reads the signed-in account name", () => { |
| 230 | expect(expectSuccess(decodeViewerJson(asJson({ user: { name: "bilal@acme.dev" } })))).toBe( |
| 231 | "bilal@acme.dev", |
| 232 | ); |
| 233 | }); |
| 234 | |
| 235 | it("returns nothing when nobody is signed in", () => { |
| 236 | expect(expectSuccess(decodeViewerJson(asJson({ user: null })))).toBeNull(); |
| 237 | }); |
| 238 | }); |
| 239 | |
| 240 | describe("decodeThreadsJson", () => { |
| 241 | it("takes every real comment of every thread, oldest first", () => { |
| 242 | const comments = expectSuccess( |
| 243 | decodeThreadsJson( |
| 244 | asJson({ |
| 245 | value: [ |
| 246 | { |
| 247 | id: 2, |
| 248 | comments: [ |
| 249 | { |
| 250 | id: 1, |
| 251 | content: "Second remark.", |
| 252 | author: { displayName: "Julius", uniqueName: "julius@acme.dev" }, |
| 253 | publishedDate: "2026-07-03T00:00:00Z", |
| 254 | }, |
| 255 | ], |
| 256 | }, |
| 257 | { |
| 258 | id: 1, |
| 259 | comments: [ |
| 260 | // Azure's own activity notes are events rather than remarks. |
| 261 | { id: 1, content: "Bilal voted", commentType: "system", publishedDate: "x" }, |
| 262 | { |
| 263 | id: 2, |
| 264 | content: "First remark.", |
| 265 | author: { displayName: "Bilal", uniqueName: "bilal@acme.dev" }, |
| 266 | publishedDate: "2026-07-02T00:00:00Z", |
| 267 | }, |
| 268 | ], |
| 269 | }, |
| 270 | ], |
| 271 | }), |
| 272 | ), |
| 273 | ); |
| 274 | |
| 275 | expect(comments.map((comment) => comment.body)).toEqual(["First remark.", "Second remark."]); |
| 276 | expect(comments[0]).toMatchObject({ |
| 277 | kind: "issue-comment", |
| 278 | author: { login: "bilal@acme.dev" }, |
| 279 | }); |
| 280 | }); |
| 281 | |
| 282 | it("reads a thread pinned to a file as a review comment", () => { |
| 283 | const comments = expectSuccess( |
| 284 | decodeThreadsJson( |
| 285 | asJson({ |
| 286 | value: [ |
| 287 | { |
| 288 | id: 3, |
| 289 | threadContext: { filePath: "/src/app.ts" }, |
| 290 | comments: [{ id: 1, content: "Rename this.", publishedDate: "2026-07-02T00:00:00Z" }], |
| 291 | }, |
| 292 | ], |
| 293 | }), |
| 294 | ), |
| 295 | ); |
| 296 | |
| 297 | expect(comments[0]).toMatchObject({ kind: "review-comment", path: "/src/app.ts" }); |
| 298 | }); |
| 299 | |
| 300 | it("keeps the replies under a thread, which are as much of the conversation", () => { |
| 301 | const comments = expectSuccess( |
| 302 | decodeThreadsJson( |
| 303 | asJson({ |
| 304 | value: [ |
| 305 | { |
| 306 | id: 4, |
| 307 | threadContext: { filePath: "/src/app.ts" }, |
| 308 | comments: [ |
| 309 | { id: 1, content: "Rename this.", publishedDate: "2026-07-02T00:00:00Z" }, |
| 310 | { id: 2, content: "Renamed.", publishedDate: "2026-07-02T01:00:00Z" }, |
| 311 | { id: 3, content: "Thanks.", publishedDate: "2026-07-02T02:00:00Z" }, |
| 312 | ], |
| 313 | }, |
| 314 | ], |
| 315 | }), |
| 316 | ), |
| 317 | ); |
| 318 | |
| 319 | expect(comments.map((comment) => comment.id)).toEqual(["4:1", "4:2", "4:3"]); |
| 320 | }); |
| 321 | |
| 322 | it("drops deleted threads and threads with nothing to show", () => { |
| 323 | const comments = expectSuccess( |
| 324 | decodeThreadsJson( |
| 325 | asJson({ |
| 326 | value: [ |
| 327 | { |
| 328 | id: 1, |
| 329 | isDeleted: true, |
| 330 | comments: [{ id: 1, content: "gone", publishedDate: "2026-07-02T00:00:00Z" }], |
| 331 | }, |
| 332 | { id: 2, comments: [] }, |
| 333 | { |
| 334 | id: 3, |
| 335 | comments: [{ id: 1, content: " ", publishedDate: "2026-07-02T00:00:00Z" }], |
| 336 | }, |
| 337 | ], |
| 338 | }), |
| 339 | ), |
| 340 | ); |
| 341 | |
| 342 | expect(comments).toEqual([]); |
| 343 | }); |
| 344 | }); |
| 345 | |
| 346 | describe("decodeIterationsJson", () => { |
| 347 | const iteration = (id: number, head: string, base: string) => ({ |
| 348 | id, |
| 349 | sourceRefCommit: { commitId: head }, |
| 350 | commonRefCommit: { commitId: base }, |
| 351 | targetRefCommit: { commitId: base }, |
| 352 | }); |
| 353 | |
| 354 | it("reads every push in order, oldest first", () => { |
| 355 | const iterations = expectSuccess( |
| 356 | decodeIterationsJson( |
| 357 | asJson({ value: [iteration(2, "bbb", "base"), iteration(1, "aaa", "base")] }), |
| 358 | ), |
| 359 | ); |
| 360 | |
| 361 | expect(iterations.map((entry) => entry.id)).toEqual([1, 2]); |
| 362 | expect(iterations.at(-1)).toEqual({ id: 2, headCommit: "bbb", mergeBaseCommit: "base" }); |
| 363 | }); |
| 364 | |
| 365 | it("skips a push Azure could not place both ends of", () => { |
| 366 | // A patch is taken over a range, and an iteration missing either end names no range at all. |
| 367 | const iterations = expectSuccess( |
| 368 | decodeIterationsJson( |
| 369 | asJson({ |
| 370 | value: [{ id: 1, sourceRefCommit: { commitId: "aaa" } }, iteration(2, "bbb", "base")], |
| 371 | }), |
| 372 | ), |
| 373 | ); |
| 374 | |
| 375 | expect(iterations.map((entry) => entry.id)).toEqual([2]); |
| 376 | }); |
| 377 | }); |
| 378 | |
| 379 | describe("decodeIterationChangesJson", () => { |
| 380 | it("names each changed file without the slash Azure leads its paths with", () => { |
| 381 | const page = expectSuccess( |
| 382 | decodeIterationChangesJson( |
| 383 | asJson({ |
| 384 | changeEntries: [ |
| 385 | { changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }, |
| 386 | { |
| 387 | changeType: "edit", |
| 388 | item: { path: "/README.md", objectId: "8f80", originalObjectId: "0ca4" }, |
| 389 | }, |
| 390 | { changeType: "delete", item: { path: "/OLD.md", originalObjectId: "1111" } }, |
| 391 | ], |
| 392 | }), |
| 393 | ), |
| 394 | ); |
| 395 | |
| 396 | expect(page.changes.map((change) => [change.path, change.changeKind])).toEqual([ |
| 397 | ["DEMO.md", "new"], |
| 398 | ["README.md", "change"], |
| 399 | ["OLD.md", "deleted"], |
| 400 | ]); |
| 401 | }); |
| 402 | |
| 403 | it("keeps a space at the end of a file's name, which belongs to the name", () => { |
| 404 | // Git will carry a name that ends in a space, and the patch and the viewed mark are both |
| 405 | // keyed by it. Tidying it here files the change under a name nothing else uses. |
| 406 | const page = expectSuccess( |
| 407 | decodeIterationChangesJson( |
| 408 | asJson({ |
| 409 | changeEntries: [ |
| 410 | { changeType: "edit", item: { path: "/docs/readme.md ", objectId: "ec00" } }, |
| 411 | { |
| 412 | changeType: "rename", |
| 413 | sourceServerItem: "/docs/old.md ", |
| 414 | item: { path: "/docs/moved.md", objectId: "aaaa", originalObjectId: "aaaa" }, |
| 415 | }, |
| 416 | ], |
| 417 | }), |
| 418 | ), |
| 419 | ); |
| 420 | |
| 421 | expect(page.changes.map((change) => [change.path, change.oldPath])).toEqual([ |
| 422 | ["docs/readme.md ", "docs/readme.md "], |
| 423 | ["docs/moved.md", "docs/old.md "], |
| 424 | ]); |
| 425 | }); |
| 426 | |
| 427 | it("reads a rename as one file that moved, and says whether it also changed", () => { |
| 428 | const page = expectSuccess( |
| 429 | decodeIterationChangesJson( |
| 430 | asJson({ |
| 431 | changeEntries: [ |
| 432 | { |
| 433 | changeType: "rename", |
| 434 | sourceServerItem: "/docs/old.md", |
| 435 | item: { path: "/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" }, |
| 436 | }, |
| 437 | { |
| 438 | changeType: "edit, rename", |
| 439 | sourceServerItem: "/src/old.ts", |
| 440 | item: { path: "/src/new.ts", objectId: "bbbb", originalObjectId: "cccc" }, |
| 441 | }, |
| 442 | ], |
| 443 | }), |
| 444 | ), |
| 445 | ); |
| 446 | |
| 447 | expect(page.changes).toEqual([ |
| 448 | { |
| 449 | path: "docs/new.md", |
| 450 | oldPath: "docs/old.md", |
| 451 | changeKind: "rename-pure", |
| 452 | objectId: "aaaa", |
| 453 | originalObjectId: "aaaa", |
| 454 | }, |
| 455 | { |
| 456 | path: "src/new.ts", |
| 457 | oldPath: "src/old.ts", |
| 458 | changeKind: "rename-changed", |
| 459 | objectId: "bbbb", |
| 460 | originalObjectId: "cccc", |
| 461 | }, |
| 462 | ]); |
| 463 | }); |
| 464 | |
| 465 | it("drops the folders Azure lists alongside the files that changed", () => { |
| 466 | // A review shows files, and a folder has no content on either side to show for one. |
| 467 | const page = expectSuccess( |
| 468 | decodeIterationChangesJson( |
| 469 | asJson({ |
| 470 | changeEntries: [ |
| 471 | { changeType: "add", item: { path: "/docs", isFolder: true, gitObjectType: "tree" } }, |
| 472 | { changeType: "add", item: { path: "/docs/page.md", objectId: "dddd" } }, |
| 473 | ], |
| 474 | }), |
| 475 | ), |
| 476 | ); |
| 477 | |
| 478 | expect(page.changes.map((change) => change.path)).toEqual(["docs/page.md"]); |
| 479 | }); |
| 480 | |
| 481 | it("carries where the next page of a long change starts", () => { |
| 482 | const page = expectSuccess( |
| 483 | decodeIterationChangesJson( |
| 484 | asJson({ |
| 485 | changeEntries: [{ changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }], |
| 486 | nextSkip: 2000, |
| 487 | }), |
| 488 | ), |
| 489 | ); |
| 490 | |
| 491 | expect(page.nextSkip).toBe(2000); |
| 492 | }); |
| 493 | |
| 494 | it("reads the last page, which names no page after it, as the end of the change", () => { |
| 495 | const page = expectSuccess( |
| 496 | decodeIterationChangesJson( |
| 497 | asJson({ |
| 498 | changeEntries: [{ changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }], |
| 499 | }), |
| 500 | ), |
| 501 | ); |
| 502 | |
| 503 | expect(page.nextSkip).toBeNull(); |
| 504 | }); |
| 505 | |
| 506 | it("reads where a rename came from out of either of the two places Azure names it", () => { |
| 507 | // The iteration-changes route answers with `originalPath`; the commit routes answer with |
| 508 | // `sourceServerItem`, and both are the same fact under two names. |
| 509 | const page = expectSuccess( |
| 510 | decodeIterationChangesJson( |
| 511 | asJson({ |
| 512 | changeEntries: [ |
| 513 | { |
| 514 | changeType: "rename", |
| 515 | originalPath: "/docs/old.md", |
| 516 | item: { path: "/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" }, |
| 517 | }, |
| 518 | ], |
| 519 | }), |
| 520 | ), |
| 521 | ); |
| 522 | |
| 523 | expect(page.changes.at(0)?.oldPath).toBe("docs/old.md"); |
| 524 | }); |
| 525 | }); |
| 526 | |
| 527 | describe("decodeItemContentJson", () => { |
| 528 | it("reads the file's text out of the envelope Azure wraps it in", () => { |
| 529 | expect( |
| 530 | expectSuccess(decodeItemContentJson(asJson({ path: "/a.md", content: "one\ntwo" }))), |
| 531 | ).toEqual({ contents: "one\ntwo", isBinary: false }); |
| 532 | }); |
| 533 | |
| 534 | it("reads an empty file as empty rather than as a failure to look", () => { |
| 535 | expect(expectSuccess(decodeItemContentJson(asJson({ path: "/a.md" })))).toEqual({ |
| 536 | contents: "", |
| 537 | isBinary: false, |
| 538 | }); |
| 539 | }); |
| 540 | |
| 541 | it("keeps Azure's own word that a file is binary", () => { |
| 542 | // Which it answers base64-encoded, so nothing in the text it sent would give it away. |
| 543 | expect( |
| 544 | expectSuccess( |
| 545 | decodeItemContentJson( |
| 546 | asJson({ path: "/logo.png", content: "b2xk", contentMetadata: { isBinary: true } }), |
| 547 | ), |
| 548 | ), |
| 549 | ).toEqual({ contents: "b2xk", isBinary: true }); |
| 550 | }); |
| 551 | }); |
| 552 | |
| 553 | /** |
| 554 | * Every other fixture in this file is written from Azure's published contract. These are the |
| 555 | * shapes a real organisation answered with, read back through `az devops invoke` rather than |
| 556 | * `az rest`: the extension hands over the route's own body with one key of its own added, and a |
| 557 | * live repository leaves out fields the contract documents. |
| 558 | */ |
| 559 | describe("what az devops invoke answers with", () => { |
| 560 | it("reads the envelope the extension adds its own continuation token to", () => { |
| 561 | // Set on every JSON body it returns, from a response header these routes do not send, so it |
| 562 | // arrives as null rather than not at all. Nothing reads it, and it must not fail the decode. |
| 563 | expect( |
| 564 | expectSuccess(decodeThreadsJson(asJson({ value: [], count: 0, continuation_token: null }))), |
| 565 | ).toEqual([]); |
| 566 | |
| 567 | expect( |
| 568 | expectSuccess( |
| 569 | decodeIterationsJson( |
| 570 | asJson({ |
| 571 | count: 1, |
| 572 | continuation_token: null, |
| 573 | value: [ |
| 574 | { |
| 575 | id: 1, |
| 576 | sourceRefCommit: { commitId: "f4031105213c68f197cd46ea303bb5e72acc889a" }, |
| 577 | commonRefCommit: { commitId: "acbc805382edd6c38b8d6de6ba9f9bba9da4ad35" }, |
| 578 | }, |
| 579 | ], |
| 580 | }), |
| 581 | ), |
| 582 | ), |
| 583 | ).toEqual([ |
| 584 | { |
| 585 | id: 1, |
| 586 | headCommit: "f4031105213c68f197cd46ea303bb5e72acc889a", |
| 587 | mergeBaseCommit: "acbc805382edd6c38b8d6de6ba9f9bba9da4ad35", |
| 588 | }, |
| 589 | ]); |
| 590 | }); |
| 591 | |
| 592 | it("keeps the files of a change that names neither their object type nor their page after it", () => { |
| 593 | // A live iteration states `changeType`, `item.path` and `item.objectId` and nothing else: no |
| 594 | // `gitObjectType`, no `isFolder`, and no `nextSkip` on the only page. Each of those absences |
| 595 | // is what the defaults in the decoder are for, and reading any of them as stated would drop |
| 596 | // every file of every Azure change request. |
| 597 | const page = expectSuccess( |
| 598 | decodeIterationChangesJson( |
| 599 | asJson({ |
| 600 | continuation_token: null, |
| 601 | changeEntries: [ |
| 602 | { changeType: "add", item: { path: "/DEMO.md", objectId: "EC005DB24" } }, |
| 603 | { changeType: "edit", item: { path: "/README.md", objectId: "8F8047A49" } }, |
| 604 | ], |
| 605 | }), |
| 606 | ), |
| 607 | ); |
| 608 | |
| 609 | expect(page.nextSkip).toBeNull(); |
| 610 | expect(page.changes).toEqual([ |
| 611 | { |
| 612 | path: "DEMO.md", |
| 613 | oldPath: "DEMO.md", |
| 614 | changeKind: "new", |
| 615 | objectId: "EC005DB24", |
| 616 | originalObjectId: null, |
| 617 | }, |
| 618 | { |
| 619 | path: "README.md", |
| 620 | oldPath: "README.md", |
| 621 | changeKind: "change", |
| 622 | objectId: "8F8047A49", |
| 623 | originalObjectId: null, |
| 624 | }, |
| 625 | ]); |
| 626 | }); |
| 627 | |
| 628 | it("reads a text file whose content type Azure calls a stream", () => { |
| 629 | // `contentMetadata` comes back for a markdown file with `application/octet-stream` on it and |
| 630 | // no `isBinary` at all, so the content type is not the field to ask, and its absence is the |
| 631 | // answer that the text is text. |
| 632 | expect( |
| 633 | expectSuccess( |
| 634 | decodeItemContentJson( |
| 635 | asJson({ |
| 636 | path: "/README.md", |
| 637 | objectId: "8f8047a49", |
| 638 | gitObjectType: "blob", |
| 639 | content: "# T3Demo\n", |
| 640 | contentMetadata: { |
| 641 | contentType: "application/octet-stream", |
| 642 | encoding: 65001, |
| 643 | extension: "md", |
| 644 | fileName: "README.md", |
| 645 | }, |
| 646 | continuation_token: null, |
| 647 | }), |
| 648 | ), |
| 649 | ), |
| 650 | ).toEqual({ contents: "# T3Demo\n", isBinary: false }); |
| 651 | }); |
| 652 | }); |