Skip to content

fix: unblock 7 skipped integration tests in client.test.ts#53

Open
evans-sam wants to merge 9 commits into
masterfrom
46-7-integration-tests-in-clienttestts-skipped-during-bun-migration-hangstimeouts
Open

fix: unblock 7 skipped integration tests in client.test.ts#53
evans-sam wants to merge 9 commits into
masterfrom
46-7-integration-tests-in-clienttestts-skipped-during-bun-migration-hangstimeouts

Conversation

@evans-sam
Copy link
Copy Markdown
Owner

@evans-sam evans-sam commented Apr 17, 2026

Closes #46.

Summary

  • Production-affecting src fix: src/http.ts double-callback bug. The .catch after .then in HttpClient.request() re-invoked the user callback whenever the user callback itself threw. Switched to .then(onFulfilled, onRejected) so onRejected only sees fetch-layer rejections; exceptions from user code now propagate as a swallowed promise rejection instead of firing the callback a second time with the user's own thrown error.
  • Test-layer fix: Replaced seven bare assert(x) sites with assert.ok(x). import * as assert from 'node:assert' gives a namespace object that isn't callable under Bun's ESM, so those sites threw TypeError: assert is not a function — either failing tests fast (when at the test body) or silently masking done() (when inside a callback), producing the 5000ms hangs in 7 integration tests in client.test.ts skipped during Bun migration (hangs/timeouts) #46.
  • Rewrote the two should allow passing in XML strings tests. The originals asserted err truthy and raw.indexOf('html') !== -1, conditions the test server couldn't produce. They were phantom-passing in upstream node-soap via a chained .close(() => done()) that fired done before the SOAP callback. Rewrite now verifies what _xml actually does: substitute the raw XML into client.lastRequest. The async rewrite also propagates errors to done(err) so future regressions produce visible failures rather than silent passes.
  • Unskipped seven tests (8 it.skip sites — should allow passing in XML strings has sync + async variants). Each site runs twice under the streaming / non-streaming describes, producing 16 new passing tests.

Final count: 458 pass / 2 skip / 0 fail. The two remaining skips (lines ~2186, ~2267) are out of scope for #46 — one's a pre-existing namespace-array-ordering concern, the other's a Node-only saxStream API.

Design & plan

Spec and implementation plan are committed on this branch for reviewer context:

Commits (review in order)

  1. fix(http): don't double-invoke callback when user callback throws — src change, isolated.
  2. test(client): replace bare assert() with assert.ok() for ESM compat — 7 targeted replacements, no unskipping.
  3. test(client): rewrite broken XML-strings tests to verify _xml substitution — rewrites stay it.skip at this commit to keep the diff content-only.
  4. test(client): unskip integration tests now that root causes are fixed — 8 it.skipit flips.

Task 6 (conditional MTOM buffer-encoding fix) in the plan was not needed — all MTOM tests passed on first unskip.

Test plan

  • bun test — 458 pass / 2 skip / 0 fail
  • bun run build — no errors
  • bun run lint — clean
  • CI passes on this branch

Related

Summary by CodeRabbit

  • Bug Fixes

    • Prevented duplicate callback invocation during HTTP request handling to avoid hung or inconsistent outcomes.
  • Tests

    • Re-enabled several integration tests, stabilized multipart/attachment and SOAP behaviors, updated assertions for ESM compatibility, and revised XML-string tests to validate request payloads.
  • Documentation

    • Added a design and step-by-step implementation plan to unblock and verify the affected tests.

… tests

Captures the three distinct root causes behind the seven it.skip
markers added in 00b0b6b: a src/http.ts double-callback bug where
user-callback throws re-invoke callback, bare assert(x) calls that
fail under ESM, and two phantom-passing XML-strings tests inherited
from upstream node-soap. Outlines a single bundled PR with
commit-by-concern structure.

Refs #46.
Seven-task, five-commit plan derived from
specs/2026-04-16-fix-skipped-client-tests-design.md:

  1. Baseline verification
  2. src/http.ts fetch-chain restructure
  3. Seven bare assert() -> assert.ok() replacements
  4. XML-strings test rewrites
  5. Unskip eight it.skip sites
  6. Conditional MTOM buffer-encoding fix
  7. Final verification + PR prep

Refs #46.
HttpClient.request() wrapped the fetch chain as
.then(success).catch(catchErr). The .catch caught three cases:

  1. fetch-level rejections (network errors) — correct
  2. explicit callback(err); throw err; from boundary / parseMTOMResp
     paths — correct
  3. exceptions thrown inside the user-supplied callback when invoked
     at the handleBody call site — INCORRECT

Case 3 caused callback to fire a second time with the user's own
thrown error repackaged as a transport error. In tests this masks a
single assertion failure as a double done() that leaves the test
timing out at 5000ms. In production it leaks user-code exceptions
back through the callback API.

Switch to .then(onFulfilled, onRejected). onRejected only fires for
rejection of the fetch promise itself; exceptions inside onFulfilled
now propagate as rejection of the returned responsePromise, which the
existing tail .catch(() => {}) swallows. Behavior preserved for the
three legitimate cases:

- Fetch failure: callback fires once; promise rejects for awaiters.
- parseMTOMResp / boundary errors: inline callback(err); throw err;
  still calls callback once, then rejects the promise.
- User callback throws: promise rejects and is swallowed by the tail
  no-op. Callback is not re-invoked. Matches normal Node/browser
  semantics.

Refs #46.
test/client.test.ts uses 'import * as assert from node:assert'. The
namespace object returned by that import is not callable under Bun's
ESM — bare assert(x) throws 'TypeError: assert is not a function'.
Under CJS/Mocha require('assert') returned a callable function, so
the pattern worked there.

When the throw occurred synchronously at the test body (lines 74,
1864), the test failed fast with the TypeError. When it occurred
inside an async callback (lines 301, 308, 387, 421, 790), done()
never fired and the test timed out — matching the hang symptom in
issue #46.

Convert the seven sites to assert.ok(). Leaves existing passes
unchanged (skipped tests stay skipped at this commit); unskipping
happens in a later commit once the src-level double-callback fix
has landed.

Refs #46.
…ution

The two 'should allow passing in XML strings' tests asserted err
truthy and raw.indexOf('html') !== -1. The test server always
returned a valid empty SOAP envelope regardless of request body, so
neither assertion could ever hold. Upstream node-soap (verbatim
identical code) had an extra .close(() => { done(); }) chained after
.listen(...), which closed the server and fired done() from the close
handler before the SOAP callback could run. Under Mocha's bail-on-
first-done behavior the body assertions never actually executed;
under Bun they can't pass.

No original intent to port. Rewrite both tests to verify what _xml
actually does: substitute the raw XML into the outgoing SOAP request
body. Assertions now target client.lastRequest.includes(xmlStr). The
async rewrite uses a captured client reference and propagates errors
to done() so that a future regression produces a visible failure
rather than a passing phantom.

Kept it.skip at this commit so the diff is review-scoped to content
changes. Unskipping lands in the next commit.

Refs #46.
Flip 8 it.skip -> it sites: cached-wsdl callback (66) and promise
(1857) variants, the two rewritten XML-strings tests (163, 1910),
the MTOM trio (254, 376, 397), and the soap12 headers test (774).
Each site runs under both 'SOAP Client' and 'SOAP Client (with
streaming)' describes, so 8 flips produces 16 new passing tests.

Two it.skip sites remain — both outside the forEach wrapper and
out of scope for #46 (pre-existing namespace-array-ordering concern
on line ~2186 and a Node-only saxStream API on line ~2267).

Final count: 458 pass / 2 skip / 0 fail.

Closes #46.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 17, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e76d5c80-ea20-4211-b82c-91c5000c4dd3

📥 Commits

Reviewing files that changed from the base of the PR and between 3911ae3 and ffd6ad5.

📒 Files selected for processing (1)
  • test/client.test.ts
✅ Files skipped from review due to trivial changes (1)
  • test/client.test.ts

📝 Walkthrough

Walkthrough

Refactors HttpClient.request() promise handling to avoid double-invoking user callbacks when they throw, adds planning and design docs to unblock skipped client integration tests, and re-enables/rewrites several tests in test/client.test.ts (XML-string validation and ESM-compatible assertions).

Changes

Unskip client tests, fix HTTP promise flow, planning/design

Layer / File(s) Summary
Design / Plan
plans/2026-04-16-fix-skipped-client-tests.md, specs/2026-04-16-fix-skipped-client-tests-design.md
Adds a stepwise plan and a design doc describing root causes, a staged fix plan (promise-chain refactor, ESM assertion fixes, XML-string test rewrites, flipping selected it.skip to it), and verification steps.
Core Control Flow
src/http.ts
Changes HttpClient.request() promise handling from .then(async ...).catch(...) to .then(async ..., (err) => ...), moving timeout cleanup, callback invocation, and rethrow into the rejection handler to avoid double-invocation when user callbacks throw; preserves outer rejection swallowing for non-awaited callers.
Tests: activation & rewrites
test/client.test.ts
Flips multiple it.skipit, converts bare assert(...) to assert.ok(...) for ESM compatibility, rewrites two XML-string tests to use a local HTTP server and assert _xml reached the request via client.lastRequest, adjusts minor callback signatures/locals, and re-enables MTOM/SOAP 1.2-related tests.

Sequence Diagram(s)

sequenceDiagram
  participant Test as Test (client.test.ts)
  participant Client as Client (createClient / createClientAsync)
  participant Http as HttpClient.request
  participant Fetch as fetch
  participant Server as Test Server

  Test->>Client: invoke MyOperation(_xml) / MyOperationAsync(_xml)
  Client->>Http: HttpClient.request(callback)
  Http->>Fetch: fetch(request)
  Fetch->>Server: HTTP request (body contains _xml)
  Server-->>Fetch: HTTP response
  Fetch-->>Http: response resolved

  alt user callback throws
    Http->>Http: rejection handler via .then(..., onRejected) clears timeout, calls callback(err), rethrows
  else normal path
    Http->>Http: fulfillment handler continues normal callback flow
  end

  Http-->>Client: callback invoked exactly once
  Client-->>Test: test inspects `client.lastRequest` and asserts `_xml`
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 I nudged the promise, smoothed its hop,

Timeouts tucked, no callbacks double-flop,
Tests unskipped, XML snug in the nest,
LastRequest holds the crumbs from the quest,
Hooray — the build can rest!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: unblocking 7 skipped integration tests in client.test.ts, which is the primary objective of the PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 46-7-integration-tests-in-clienttestts-skipped-during-bun-migration-hangstimeouts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

prettier --check is part of the CI build-test job. The XML-strings
rewrites in cf92720 and the two markdown docs included line-wrapped
calls that fit within the project's max line width; prettier rejoins
them. Pure cosmetic change — no test behavior or content affected.

Runs: 458 pass / 2 skip / 0 fail (unchanged).

Refs #46.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
test/client.test.ts (2)

163-202: ⚠️ Potential issue | 🟡 Minor

Ensure server teardown runs on all failure paths in the callback-style XML test.

In this block, server.close() only runs on the success path. If createClient or MyOperation fails, the open server can leak and cascade into later test instability.

Suggested hardening
-      const server = http
+      const server = http
         .createServer(function (req, res) {
@@
-        .listen(port, hostname, function () {
+        .listen(port, hostname, function () {
+          const finish = (err?: any) => server.close(() => done(err));
           soap.createClient(
@@
             function (err, client) {
-              assert.ifError(err);
+              if (err) return finish(err);
               assert.ok(client);
               client.MyOperation({ _xml: xmlStr }, function (err2) {
-                assert.ifError(err2);
+                if (err2) return finish(err2);
                 assert.ok(
                   client.lastRequest.includes(xmlStr),
                   'lastRequest should contain the raw _xml content',
                 );
-                server.close();
-                done();
+                finish();
               });
             },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/client.test.ts` around lines 163 - 202, The test opens an HTTP server
but only calls server.close() on the success path, risking leaks if
soap.createClient or client.MyOperation error; update the callbacks used by
soap.createClient and client.MyOperation so the server is always closed before
calling done or asserting failures (e.g., call server.close() in each error
branch or wrap the callback bodies to run server.close() in a finally-style
step), ensuring server.close() runs whether assert.ifError fails or an error is
passed to done; refer to the server instance, soap.createClient callback, and
client.MyOperation callback when making the changes.

1-2462: ⚠️ Potential issue | 🟡 Minor

Prettier check is currently failing for this file.

Please run formatting on test/client.test.ts before merge so CI is green.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/client.test.ts` around lines 1 - 2462, Prettier formatting failed for
this test file; run your project's formatter (e.g. npx prettier --write
test/client.test.ts or the repository's format script) to reformat the file and
then commit the updated file so CI passes; ensure you re-run lint/format hooks
if present and that the changes include only whitespace/formatting edits in the
test file (tests such as the top-level describe('SOAP Client'...) and other test
blocks should remain semantically unchanged).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@plans/2026-04-16-fix-skipped-client-tests.md`:
- Around line 56-59: Replace the stale expected test totals "150 pass / 18 skip
/ 0 fail" and "166 pass / 2 skip / 0 fail" in the plan with the current reported
totals ("458 pass / 2 skip / 0 fail" or the canonical current PR counts), and
update the same strings found at the other occurrences mentioned ("150/18" and
"166/2" at the other checkpoints) so all references (the literal phrases "150
pass / 18 skip / 0 fail", "166 pass / 2 skip / 0 fail", and the shorthand
"150/18" and "166/2") are consistent with the current PR numbers; ensure the
text around "Record this number. After Task 5" and the other sections still
reads correctly after replacing the numbers.
- Around line 1-1229: The plan file has Markdown lint failures (MD038/MD040):
run the project's formatter/linter (e.g., bun run lint and/or prettier --write)
and add explicit fence languages to all fenced code blocks in
plans/2026-04-16-fix-skipped-client-tests.md (use bash for shell commands, ts
for TypeScript blocks, text where appropriate), and ensure fenced blocks follow
markdownlint spacing rules (blank lines before/after fences) so MD038/MD040 are
resolved; re-run the linter to verify no Markdown violations and commit the
fixes referencing the file and MD038/MD040.

In `@specs/2026-04-16-fix-skipped-client-tests-design.md`:
- Around line 182-184: Update the verification counts in the "Update
verification counts to match current branch reality." section: replace the stale
"166 pass / 2 skip / 0 fail" with the current reported test results "458 pass /
2 skip / 0 fail" so the spec's three-point checklist (bun test / build / lint)
accurately reflects the branch; edit the line in the diff where the counts are
listed to the new values.

---

Outside diff comments:
In `@test/client.test.ts`:
- Around line 163-202: The test opens an HTTP server but only calls
server.close() on the success path, risking leaks if soap.createClient or
client.MyOperation error; update the callbacks used by soap.createClient and
client.MyOperation so the server is always closed before calling done or
asserting failures (e.g., call server.close() in each error branch or wrap the
callback bodies to run server.close() in a finally-style step), ensuring
server.close() runs whether assert.ifError fails or an error is passed to done;
refer to the server instance, soap.createClient callback, and client.MyOperation
callback when making the changes.
- Around line 1-2462: Prettier formatting failed for this test file; run your
project's formatter (e.g. npx prettier --write test/client.test.ts or the
repository's format script) to reformat the file and then commit the updated
file so CI passes; ensure you re-run lint/format hooks if present and that the
changes include only whitespace/formatting edits in the test file (tests such as
the top-level describe('SOAP Client'...) and other test blocks should remain
semantically unchanged).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3d8181ce-4106-4802-a86c-038c4a583c9d

📥 Commits

Reviewing files that changed from the base of the PR and between 3f80ba7 and cf92720.

📒 Files selected for processing (4)
  • plans/2026-04-16-fix-skipped-client-tests.md
  • specs/2026-04-16-fix-skipped-client-tests-design.md
  • src/http.ts
  • test/client.test.ts

Comment on lines +1 to +1229
# Unblock Skipped Client Integration Tests — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Unblock the seven skipped integration tests in `test/client.test.ts` (issue [#46](https://github.com/evans-sam/fetch-soap/issues/46)) by fixing the real root causes: a `src/http.ts` double-callback bug, seven bare `assert(x)` sites that fail under ESM, and two phantom-passing XML-strings tests inherited from upstream node-soap.

**Architecture:** Single bundled PR, five commits organized by concern. One src fix restructures a fetch promise chain so that user-callback throws no longer trigger a second callback invocation. The rest are test-layer changes: targeted `assert(x)` → `assert.ok(x)` replacements, a rewrite of the two XML-strings tests to verify `_xml` substitution against `client.lastRequest`, and flipping `it.skip` markers to `it`. A sixth conditional commit addresses a binary-attachment encoding bug in `src/http.ts` only if MTOM diagnosis surfaces it.

**Tech Stack:** TypeScript, Bun runtime (`bun:test`, Bun's built-in `fetch`), `node:http` for test servers, `node:assert`.

**Reference:** [specs/2026-04-16-fix-skipped-client-tests-design.md](../specs/2026-04-16-fix-skipped-client-tests-design.md)

---

## File Structure

Files touched:

- **Modify:** `src/http.ts` — restructure the fetch promise chain in `HttpClient.request()` (lines 247–323). Conditional sixth commit may also adjust the multipart-body construction block (lines 158–170).
- **Modify:** `test/client.test.ts` — seven bare-assert replacements (lines 74, 301, 308, 387, 421, 790, 1864); two XML-strings test rewrites (lines 163–194 and 1910–1923); eight `it.skip` → `it` flips (lines 66, 163, 254, 376, 397, 774, 1857, 1910).

No new files created. No deletions.

---

## Task 1: Pre-work baseline

Confirm the starting state so every later step has a trustworthy anchor.

**Files:**
- Read-only: `test/client.test.ts`, `src/http.ts`, `package.json`

- [ ] **Step 1: Confirm clean working tree on the correct branch**

```bash
git status
git branch --show-current
```

Expected output: `nothing to commit, working tree clean` and branch `46-7-integration-tests-in-clienttestts-skipped-during-bun-migration-hangstimeouts`.

- [ ] **Step 2: Install dependencies**

```bash
bun install
```

Expected: no errors. Dependencies resolve from `bun.lock`.

- [ ] **Step 3: Capture baseline test count**

```bash
bun test 2>&1 | tail -10
```

Expected: `150 pass / 18 skip / 0 fail` (or very close — minor variations in pass count are fine; what matters is 0 fail and 18 skip).

Record this number. After Task 5, the expected counts are 166 pass / 2 skip / 0 fail.

- [ ] **Step 4: Confirm baseline typecheck and lint**

```bash
bun run build && bun run lint
```

Expected: both complete successfully with no errors.

---

## Task 2: Fix `src/http.ts` double-callback (commit 1)

The `.catch()` after `.then()` in `HttpClient.request()` catches throws from inside the user-supplied callback and re-invokes the callback with that thrown error — double-fire. Switch to `.then(onFulfilled, onRejected)` so `onRejected` only sees real fetch-layer rejections.

**Files:**
- Modify: `src/http.ts:247-323`

- [ ] **Step 1: Verify the exact current code block to replace**

Run:

```bash
sed -n '247,323p' src/http.ts
```

Expected first line: ` const responsePromise = fetchFn(options.url, fetchOptions)`
Expected last line: ` });`
Expected content of lines 319–323:

```ts
.catch((err) => {
if (timeoutId) clearTimeout(timeoutId);
callback(err);
throw err;
});
```

If the lines don't match, stop and re-read `src/http.ts` in full before proceeding.

- [ ] **Step 2: Apply the restructure**

Use Edit to replace this exact block:

```ts
const responsePromise = fetchFn(options.url, fetchOptions)
.then(async (response) => {
if (timeoutId) clearTimeout(timeoutId);

const headersObj = this.headersToObject(response.headers);

// Determine how to read the response body
let responseData: any;
if (this.options.parseReponseAttachments) {
responseData = await response.arrayBuffer();
} else {
responseData = await response.text();
}

const res: IHttpResponse = {
status: response.status,
statusText: response.statusText,
headers: headersObj,
data: responseData,
requestHeaders: options.headers,
};

const handleBody = (body?: string) => {
res.data = this.handleResponse(body !== undefined ? body : res.data);
callback(null, res, res.data);
return res;
};

if (this.options.parseReponseAttachments) {
const contentType = headersObj['content-type'];
const isMultipartResp = contentType && contentType.toLowerCase().indexOf('multipart/related') > -1;
if (isMultipartResp) {
let boundary;
const parsedContentType = MIMEType.parse(contentType);
if (parsedContentType) {
boundary = parsedContentType.parameters.get('boundary');
}
if (!boundary) {
const err = new Error('Missing boundary from content-type');
callback(err);
throw err;
}
return new Promise<IHttpResponse>((resolve, reject) => {
parseMTOMResp(responseData, boundary, (err, multipartResponse) => {
if (err) {
callback(err);
return reject(err);
}
// first part is the soap response
const firstPart = multipartResponse.parts.shift();
if (!firstPart || !firstPart.body) {
const parseErr = new Error('Cannot parse multipart response');
callback(parseErr);
return reject(parseErr);
}
res.mtomResponseAttachments = multipartResponse;
const decoder = new TextDecoder(this.options.encoding || 'utf-8');
const bodyStr = decoder.decode(firstPart.body);
handleBody(bodyStr);
resolve(res);
});
});
} else {
// Convert ArrayBuffer to string
const decoder = new TextDecoder(this.options.encoding || 'utf-8');
const bodyStr = decoder.decode(responseData);
return handleBody(bodyStr);
}
} else {
return handleBody();
}
})
.catch((err) => {
if (timeoutId) clearTimeout(timeoutId);
callback(err);
throw err;
});
```

With this:

```ts
const responsePromise = fetchFn(options.url, fetchOptions).then(
async (response) => {
if (timeoutId) clearTimeout(timeoutId);

const headersObj = this.headersToObject(response.headers);

// Determine how to read the response body
let responseData: any;
if (this.options.parseReponseAttachments) {
responseData = await response.arrayBuffer();
} else {
responseData = await response.text();
}

const res: IHttpResponse = {
status: response.status,
statusText: response.statusText,
headers: headersObj,
data: responseData,
requestHeaders: options.headers,
};

const handleBody = (body?: string) => {
res.data = this.handleResponse(body !== undefined ? body : res.data);
callback(null, res, res.data);
return res;
};

if (this.options.parseReponseAttachments) {
const contentType = headersObj['content-type'];
const isMultipartResp = contentType && contentType.toLowerCase().indexOf('multipart/related') > -1;
if (isMultipartResp) {
let boundary;
const parsedContentType = MIMEType.parse(contentType);
if (parsedContentType) {
boundary = parsedContentType.parameters.get('boundary');
}
if (!boundary) {
const err = new Error('Missing boundary from content-type');
callback(err);
throw err;
}
return new Promise<IHttpResponse>((resolve, reject) => {
parseMTOMResp(responseData, boundary, (err, multipartResponse) => {
if (err) {
callback(err);
return reject(err);
}
// first part is the soap response
const firstPart = multipartResponse.parts.shift();
if (!firstPart || !firstPart.body) {
const parseErr = new Error('Cannot parse multipart response');
callback(parseErr);
return reject(parseErr);
}
res.mtomResponseAttachments = multipartResponse;
const decoder = new TextDecoder(this.options.encoding || 'utf-8');
const bodyStr = decoder.decode(firstPart.body);
handleBody(bodyStr);
resolve(res);
});
});
} else {
// Convert ArrayBuffer to string
const decoder = new TextDecoder(this.options.encoding || 'utf-8');
const bodyStr = decoder.decode(responseData);
return handleBody(bodyStr);
}
} else {
return handleBody();
}
},
(err) => {
if (timeoutId) clearTimeout(timeoutId);
callback(err);
throw err;
},
);
```

Only two things change: `.then(async (response) => { ... })` becomes `.then(` with a second argument, and `.catch((err) => { ... })` becomes the second argument (with closing `)` instead of trailing `.catch`). The body of both handlers is identical to the original.

- [ ] **Step 3: Typecheck**

```bash
bun run build
```

Expected: no errors. The promise-chain refactor shouldn't introduce any type changes.

- [ ] **Step 4: Run the existing non-skipped tests to confirm no regression**

```bash
bun test 2>&1 | tail -5
```

Expected: `150 pass / 18 skip / 0 fail` (same as baseline — unchanged). The src fix shouldn't affect any currently-passing test since none of them throw from user callbacks.

- [ ] **Step 5: Commit**

```bash
git add src/http.ts
git commit -m "$(cat <<'EOF'
fix(http): don't double-invoke callback when user callback throws

HttpClient.request() wrapped the fetch chain as
.then(success).catch(catchErr). The .catch caught three cases:

1. fetch-level rejections (network errors) — correct
2. explicit callback(err); throw err; from boundary / parseMTOMResp
paths — correct
3. exceptions thrown inside the user-supplied callback when invoked
at the handleBody call site — INCORRECT

Case 3 caused callback to fire a second time with the user's own
thrown error repackaged as a transport error. In tests this masks a
single assertion failure as a double done() that leaves the test
timing out at 5000ms. In production it leaks user-code exceptions
back through the callback API.

Switch to .then(onFulfilled, onRejected). onRejected only fires for
rejection of the fetch promise itself; exceptions inside onFulfilled
now propagate as rejection of the returned responsePromise, which the
existing tail .catch(() => {}) swallows. Behavior preserved for the
three legitimate cases:

- Fetch failure: callback fires once; promise rejects for awaiters.
- parseMTOMResp / boundary errors: inline callback(err); throw err;
still calls callback once, then rejects the promise.
- User callback throws: promise rejects and is swallowed by the tail
no-op. Callback is not re-invoked. Matches normal Node/browser
semantics.

Refs #46.
EOF
)"
```

Expected: commit succeeds. No pre-commit hook installed for this repo.

---

## Task 3: Replace bare `assert(x)` with `assert.ok(x)` (commit 2)

`test/client.test.ts` uses `import * as assert from 'node:assert'`. The namespace object isn't callable under Bun ESM — bare `assert(x)` throws `TypeError: assert is not a function`. Convert the seven sites to `assert.ok(x)`.

**Files:**
- Modify: `test/client.test.ts` — lines 74, 301, 308, 387, 421, 790, 1864

- [ ] **Step 1: Verify the seven call sites before editing**

```bash
grep -n "^\s*assert(" test/client.test.ts
```

Expected output (exact):

```
74: assert(!called);
301: assert(dataHeaders['Content-Type'].indexOf('application/xop+xml') > -1);
308: assert(attachmentHeaders['Content-Disposition'].indexOf(attachment.name) > -1);
387: assert(body.contentType.indexOf('action') > -1);
421: assert(dataHeaders['Content-Type'].indexOf('application/xop+xml') > -1);
790: assert(!client.lastRequestHeaders.SOAPAction);
1864: assert(!called);
```

If counts or line numbers differ, stop and re-read `test/client.test.ts` before proceeding.

- [ ] **Step 2: Replace line 74**

Use Edit to change:

```ts
assert(!called);
```

to:

```ts
assert.ok(!called);
```

Context for uniqueness: this is the first `assert(!called)` in the file, inside the `should issue async callback for cached wsdl` test. Include surrounding lines in `old_string` if needed — e.g. the preceding line is ` });` at 73 and following is ` });` at 75.

- [ ] **Step 3: Replace line 301**

Change:

```ts
assert(dataHeaders['Content-Type'].indexOf('application/xop+xml') > -1);
assert.equal(dataHeaders['Content-ID'], contentType.start);
```

to:

```ts
assert.ok(dataHeaders['Content-Type'].indexOf('application/xop+xml') > -1);
assert.equal(dataHeaders['Content-ID'], contentType.start);
```

Using the trailing `assert.equal(...)` line disambiguates this from the identical line at 421.

- [ ] **Step 4: Replace line 308**

Change:

```ts
assert(attachmentHeaders['Content-Disposition'].indexOf(attachment.name) > -1);
```

to:

```ts
assert.ok(attachmentHeaders['Content-Disposition'].indexOf(attachment.name) > -1);
```

This text is unique in the file — no extra context needed.

- [ ] **Step 5: Replace line 387**

Change:

```ts
assert(body.contentType.indexOf('action') > -1);
```

to:

```ts
assert.ok(body.contentType.indexOf('action') > -1);
```

Unique in the file.

- [ ] **Step 6: Replace line 421**

Change:

```ts
assert(dataHeaders['Content-Type'].indexOf('application/xop+xml') > -1);
assert.equal(dataHeaders['Content-ID'], contentType.start);
done();
```

to:

```ts
assert.ok(dataHeaders['Content-Type'].indexOf('application/xop+xml') > -1);
assert.equal(dataHeaders['Content-ID'], contentType.start);
done();
```

The trailing `done();` disambiguates this from line 301.

- [ ] **Step 7: Replace line 790**

Change:

```ts
assert(!client.lastRequestHeaders.SOAPAction);
```

to:

```ts
assert.ok(!client.lastRequestHeaders.SOAPAction);
```

Unique in the file.

- [ ] **Step 8: Replace line 1864**

Change:

```ts
assert(!called);
});

it('should allow customization of httpClient', function (done) {
```

to:

```ts
assert.ok(!called);
});

it('should allow customization of httpClient', function (done) {
```

The trailing `it('should allow customization of httpClient'...` line disambiguates this from the first `assert(!called)` at line 74.

- [ ] **Step 9: Verify all seven sites are replaced**

```bash
grep -n "^\s*assert(" test/client.test.ts
```

Expected: no matches (empty output). If any remain, fix them now.

- [ ] **Step 10: Typecheck**

```bash
bun run build
```

Expected: no errors.

- [ ] **Step 11: Run existing test suite to confirm no regression**

```bash
bun test 2>&1 | tail -5
```

Expected: `150 pass / 18 skip / 0 fail`. Still identical to baseline — the skipped tests remain skipped, and the non-skipped tests don't touch the replaced lines.

- [ ] **Step 12: Commit**

```bash
git add test/client.test.ts
git commit -m "$(cat <<'EOF'
test(client): replace bare assert() with assert.ok() for ESM compat

test/client.test.ts uses 'import * as assert from node:assert'. The
namespace object returned by that import is not callable under Bun's
ESM — bare assert(x) throws 'TypeError: assert is not a function'.
Under CJS/Mocha require('assert') returned a callable function, so
the pattern worked there.

When the throw occurred synchronously at the test body (lines 74,
1864), the test failed fast with the TypeError. When it occurred
inside an async callback (lines 301, 308, 387, 421, 790), done()
never fired and the test timed out — matching the hang symptom in
issue #46.

Convert the seven sites to assert.ok(). Leaves 150 existing passes
unchanged (skipped tests stay skipped at this commit); unskipping
happens in a later commit once the src-level double-callback fix
has landed.

Refs #46.
EOF
)"
```

Expected: commit succeeds.

---

## Task 4: Rewrite the XML-strings tests (commit 3)

The two `should allow passing in XML strings` tests assert `err` truthy and `raw.indexOf('html') !== -1` — conditions the test server can never produce. They were phantom-passing in upstream node-soap via a chained `.close(() => done())` that fired `done` before the SOAP callback. Rewrite to test what `_xml` actually does: substitute the raw string into the outgoing SOAP request body, verified via `client.lastRequest`. Keep `it.skip` at this commit so the diff is review-scoped; the unskip flip happens in Task 5.

**Files:**
- Modify: `test/client.test.ts` — lines 163–194 (sync variant), lines 1910–1923 (async variant)

- [ ] **Step 1: Verify current sync variant content (lines 163–194)**

```bash
sed -n '163,194p' test/client.test.ts
```

Expected first line: ` it.skip('should allow passing in XML strings', function (done) {`
Expected last line: ` });`

If content differs, re-read the file before proceeding.

- [ ] **Step 2: Replace the sync variant**

Use Edit to replace this exact block (lines 163–194):

```ts
it.skip('should allow passing in XML strings', function (done) {
let server: http.Server | null = null;
const hostname = '127.0.0.1';
const port = testHelpers.nextTestPort();
const baseUrl = 'http://' + hostname + ':' + port;

server = http
.createServer(function (req, res) {
res.statusCode = 200;
res.write("<soapenv:Envelope xmlns:soapenv='http://schemas.xmlsoap.org/soap/envelope/'><soapenv:Body/></soapenv:Envelope>");
res.end();
})
.listen(port, hostname, function () {
soap.createClient(
testHelpers.toTestUrl(import.meta.dir + '/wsdl/default_namespace.wsdl'),
Object.assign({ envelopeKey: 'soapenv' }, meta.options),
function (err, client) {
assert.ok(client);
assert.ifError(err);

const xmlStr =
'<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">\n\t<head>\n\t\t<title>404 - Not Found</title>\n\t</head>\n\t<body>\n\t\t<h1>404 - Not Found</h1>\n\t\t<script type="text/javascript" src="http://gp1.wpc.edgecastcdn.net/00222B/beluga/pilot_rtm/beluga_beacon.js"></script>\n\t</body>\n</html>';
client.MyOperation({ _xml: xmlStr }, function (err, result, raw, soapHeader) {
assert.ok(err);
assert.notEqual(raw.indexOf('html'), -1);
done();
});
},
baseUrl,
);
});
});
```

with:

```ts
it.skip('should allow passing in XML strings', function (done) {
const xmlStr = '<custom-raw-xml>hello</custom-raw-xml>';
const hostname = '127.0.0.1';
const port = testHelpers.nextTestPort();
const baseUrl = 'http://' + hostname + ':' + port;

const server = http
.createServer(function (req, res) {
const chunks: Buffer[] = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/xml');
res.end(
"<soapenv:Envelope xmlns:soapenv='http://schemas.xmlsoap.org/soap/envelope/'>" +
'<soapenv:Body/></soapenv:Envelope>',
);
});
})
.listen(port, hostname, function () {
soap.createClient(
testHelpers.toTestUrl(import.meta.dir + '/wsdl/default_namespace.wsdl'),
Object.assign({ envelopeKey: 'soapenv' }, meta.options),
function (err, client) {
assert.ifError(err);
assert.ok(client);
client.MyOperation({ _xml: xmlStr }, function (err2) {
assert.ifError(err2);
assert.ok(
client.lastRequest.includes(xmlStr),
'lastRequest should contain the raw _xml content',
);
server.close();
done();
});
},
baseUrl,
);
});
});
```

Note: keep `it.skip` for now. The unskip happens in Task 5.

- [ ] **Step 3: Verify current async variant content (lines 1910–1923)**

```bash
sed -n '1910,1923p' test/client.test.ts
```

Expected first line: ` it.skip('should allow passing in XML strings', function (done) {`
Expected last line: ` });`

If content differs, re-read the file before proceeding.

- [ ] **Step 4: Replace the async variant**

Use Edit to replace this exact block:

```ts
it.skip('should allow passing in XML strings', function (done) {
soap
.createClientAsync(testHelpers.toTestUrl(import.meta.dir + '/wsdl/default_namespace.wsdl'), Object.assign({ envelopeKey: 'soapenv' }, meta.options), baseUrl)
.then(function (client) {
assert.ok(client);
const xmlStr =
'<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">\n\t<head>\n\t\t<title>404 - Not Found</title>\n\t</head>\n\t<body>\n\t\t<h1>404 - Not Found</h1>\n\t\t<script type="text/javascript" src="http://gp1.wpc.edgecastcdn.net/00222B/beluga/pilot_rtm/beluga_beacon.js"></script>\n\t</body>\n</html>';
return client.MyOperationAsync({ _xml: xmlStr });
})
.then(function ([result, raw, soapHeader]: any) {})
.catch(function (err) {
done();
});
});
```

with:

```ts
it.skip('should allow passing in XML strings', function (done) {
const xmlStr = '<custom-raw-xml>hello</custom-raw-xml>';
const hostname = '127.0.0.1';
const port = testHelpers.nextTestPort();
const localBaseUrl = 'http://' + hostname + ':' + port;

const server = http
.createServer(function (req, res) {
const chunks: Buffer[] = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/xml');
res.end(
"<soapenv:Envelope xmlns:soapenv='http://schemas.xmlsoap.org/soap/envelope/'>" +
'<soapenv:Body/></soapenv:Envelope>',
);
});
})
.listen(port, hostname, function () {
let capturedClient: any;
soap
.createClientAsync(testHelpers.toTestUrl(import.meta.dir + '/wsdl/default_namespace.wsdl'), Object.assign({ envelopeKey: 'soapenv' }, meta.options), localBaseUrl)
.then(function (client) {
assert.ok(client);
capturedClient = client;
return client.MyOperationAsync({ _xml: xmlStr });
})
.then(function () {
assert.ok(
capturedClient.lastRequest.includes(xmlStr),
'lastRequest should contain the raw _xml content',
);
server.close();
done();
})
.catch(function (err) {
server.close();
done(err);
});
});
});
```

Notes on the async rewrite:
- Uses `localBaseUrl` (not `baseUrl`) to avoid shadowing the outer `describe`'s `baseUrl = 'http://127.0.0.1:80'`.
- `capturedClient` hoists the client out of the first `.then` so the second `.then` can read `lastRequest`.
- `.catch` now calls `done(err)` with the error rather than swallowing it — so if the rewrite breaks, the failure is visible, not masked as a passing test.

- [ ] **Step 5: Typecheck**

```bash
bun run build
```

Expected: no errors.

- [ ] **Step 6: Run existing test suite to confirm no regression**

```bash
bun test 2>&1 | tail -5
```

Expected: `150 pass / 18 skip / 0 fail`. The two rewritten tests are still `it.skip`, so neither runs.

- [ ] **Step 7: Commit**

```bash
git add test/client.test.ts
git commit -m "$(cat <<'EOF'
test(client): rewrite broken XML-strings tests to verify _xml substitution

The two 'should allow passing in XML strings' tests asserted err
truthy and raw.indexOf('html') !== -1. The test server always
returned a valid empty SOAP envelope regardless of request body, so
neither assertion could ever hold. Upstream node-soap (verbatim
identical code) had an extra .close(() => { done(); }) chained after
.listen(...), which closed the server and fired done() from the close
handler before the SOAP callback could run. Under Mocha's bail-on-
first-done behavior the body assertions never actually executed;
under Bun they can't pass.

No original intent to port. Rewrite both tests to verify what _xml
actually does: substitute the raw XML into the outgoing SOAP request
body. Assertions now target client.lastRequest.includes(xmlStr). The
async rewrite uses a captured client reference and propagates errors
to done() so that a future regression produces a visible failure
rather than a passing phantom.

Kept it.skip at this commit so the diff is review-scoped to content
changes. Unskipping lands in the next commit.

Refs #46.
EOF
)"
```

Expected: commit succeeds.

---

## Task 5: Unskip the eight sites (commit 4)

Flip `it.skip` → `it` at all eight sites. This is the moment of truth — any remaining bugs surface here.

**Files:**
- Modify: `test/client.test.ts` — lines 66, 163, 254, 376, 397, 774, 1857, 1910

- [ ] **Step 1: Verify the eight `it.skip` sites before flipping**

```bash
grep -n "it\.skip" test/client.test.ts
```

Expected output (exact):

```
66: it.skip('should issue async callback for cached wsdl', function (done) {
163: it.skip('should allow passing in XML strings', function (done) {
254: it.skip('should send binary attachments using XOP + MTOM', function (done) {
376: it.skip('Should preserve SOAP 1.2 "action" header when sending MTOM request', function (done) {
397: it.skip('Should send MTOM request even without attachment', function (done) {
774: it.skip('should add proper headers for soap12', function (done) {
1857: it.skip('should issue async promise for cached wsdl', function (done) {
1910: it.skip('should allow passing in XML strings', function (done) {
2150: it.skip('should add namespace to array of objects', function (done) {
2231: it.skip('should return the saxStream (Node.js-specific, not supported in universal mode)', (done) => {
```

Ten `it.skip` sites total. Lines 2150 and 2231 are out of scope for #46 and must stay skipped. The other eight get flipped.

- [ ] **Step 2: Flip line 66**

Change:

```ts
it.skip('should issue async callback for cached wsdl', function (done) {
```

to:

```ts
it('should issue async callback for cached wsdl', function (done) {
```

- [ ] **Step 3: Flip line 163**

Change:

```ts
it.skip('should allow passing in XML strings', function (done) {
const xmlStr = '<custom-raw-xml>hello</custom-raw-xml>';
```

to:

```ts
it('should allow passing in XML strings', function (done) {
const xmlStr = '<custom-raw-xml>hello</custom-raw-xml>';
```

The second line disambiguates this from the async variant at line 1910.

- [ ] **Step 4: Flip line 254**

Change:

```ts
it.skip('should send binary attachments using XOP + MTOM', function (done) {
```

to:

```ts
it('should send binary attachments using XOP + MTOM', function (done) {
```

- [ ] **Step 5: Flip line 376**

Change:

```ts
it.skip('Should preserve SOAP 1.2 "action" header when sending MTOM request', function (done) {
```

to:

```ts
it('Should preserve SOAP 1.2 "action" header when sending MTOM request', function (done) {
```

- [ ] **Step 6: Flip line 397**

Change:

```ts
it.skip('Should send MTOM request even without attachment', function (done) {
```

to:

```ts
it('Should send MTOM request even without attachment', function (done) {
```

- [ ] **Step 7: Flip line 774**

Change:

```ts
it.skip('should add proper headers for soap12', function (done) {
```

to:

```ts
it('should add proper headers for soap12', function (done) {
```

- [ ] **Step 8: Flip line 1857**

Change:

```ts
it.skip('should issue async promise for cached wsdl', function (done) {
```

to:

```ts
it('should issue async promise for cached wsdl', function (done) {
```

- [ ] **Step 9: Flip line 1910**

Change:

```ts
it.skip('should allow passing in XML strings', function (done) {
const xmlStr = '<custom-raw-xml>hello</custom-raw-xml>';
```

to:

```ts
it('should allow passing in XML strings', function (done) {
const xmlStr = '<custom-raw-xml>hello</custom-raw-xml>';
```

The second line disambiguates this from the sync variant at line 163.

- [ ] **Step 10: Verify only the two out-of-scope skips remain**

```bash
grep -n "it\.skip" test/client.test.ts
```

Expected output (exact):

```
2150: it.skip('should add namespace to array of objects', function (done) {
2231: it.skip('should return the saxStream (Node.js-specific, not supported in universal mode)', (done) => {
```

If any other `it.skip` remain, flip them now.

- [ ] **Step 11: Run the full test suite**

```bash
bun test 2>&1 | tail -15
```

Expected best-case: `166 pass / 2 skip / 0 fail`. Stop and proceed directly to Task 7 (final verification).

Expected likely-case: some MTOM tests fail. If one or more of the three MTOM tests (`should send binary attachments using XOP + MTOM`, `Should preserve SOAP 1.2 "action" header when sending MTOM request`, `Should send MTOM request even without attachment`) fail, continue to Task 6.

Expected unlikely-case: non-MTOM tests fail. If so, capture the failure, stop, and investigate — this indicates a root cause not covered by the design. Common-case diagnosis:
- If a bare `assert(x)` you missed — grep for `^\s*assert(` again.
- If the XML-strings rewrite captures `lastRequest` before it's set — add a `console.log` inside the MyOperation callback and re-run with `--timeout 15000`.

- [ ] **Step 12: If Step 11 passed with 166/2/0, commit and skip Task 6**

```bash
git add test/client.test.ts
git commit -m "$(cat <<'EOF'
test(client): unskip integration tests now that root causes are fixed

Flip 8 it.skip -> it sites: cached-wsdl callback (66) and promise
(1857) variants, the two rewritten XML-strings tests (163, 1910),
the MTOM trio (254, 376, 397), and the soap12 headers test (774).
Each site runs under both 'SOAP Client' and 'SOAP Client (with
streaming)' describes, so 8 flips produces 16 new passing tests.

Two it.skip sites remain (lines 2150, 2231) — both outside the
forEach wrapper and out of scope for #46 (pre-existing namespace-
array-ordering concern and a Node-only saxStream API respectively).

Final count: 166 pass / 2 skip / 0 fail.

Closes #46.
EOF
)"
```

Then jump to Task 7.

- [ ] **Step 13: If Step 11 revealed MTOM failure(s), commit the partial-progress flip**

Some MTOM tests will fail at this commit; that's expected and Task 6 fixes them. Stage the commit anyway so the unskip is its own reviewable unit:

```bash
git add test/client.test.ts
git commit -m "$(cat <<'EOF'
test(client): unskip integration tests now that root causes are fixed

Flip 8 it.skip -> it sites. Test suite now surfaces a residual MTOM
binary-attachment failure addressed in the following commit.

Refs #46.
EOF
)"
```

Update the commit message at PR-prep time (Task 7) to reference the MTOM-fix commit.

---

## Task 6 (Conditional): Fix MTOM binary-attachment encoding bug (commit 5)

Only do this task if Task 5 Step 11 surfaced failure(s) in the MTOM tests. The expected root cause: `textEncoder.encode(part.body)` at `src/http.ts:167` treats an attachment `Buffer` as a string, corrupting binary data at null bytes.

**Files:**
- Modify: `src/http.ts:158-170` (multipart body construction)

- [ ] **Step 1: Confirm the failure mode**

Run the binary-attachments test alone with a longer timeout:

```bash
bun test test/client.test.ts -t "should send binary attachments using XOP + MTOM" --timeout 15000 2>&1 | tail -25
```

If the failure message mentions `Body does not contain part of binary data` (from the test server's `assert.ok(body.includes(PNG...))` on line 266), the binary data is corrupted in transit → confirms the encoding bug.

Other failure modes (e.g. connection refused, timeout with no clear cause) indicate a different root cause — stop and investigate before applying the fix below.

- [ ] **Step 2: Verify the exact current code block**

```bash
sed -n '158,170p' src/http.ts
```

Expected content:

```ts
const dataParts: Uint8Array[] = [textEncoder.encode(`--${boundary}\r\n`)];

let multipartCount = 0;
multipart.forEach((part) => {
Object.keys(part).forEach((key) => {
if (key !== 'body') {
dataParts.push(textEncoder.encode(`${key}: ${part[key]}\r\n`));
}
});
dataParts.push(textEncoder.encode('\r\n'), textEncoder.encode(part.body), textEncoder.encode(`\r\n--${boundary}${multipartCount === multipart.length - 1 ? '--' : ''}\r\n`));
multipartCount++;
});
options.body = concatUint8Arrays(dataParts);
```

- [ ] **Step 3: Apply the encoding fix**

Use Edit to replace this exact line:

```ts
dataParts.push(textEncoder.encode('\r\n'), textEncoder.encode(part.body), textEncoder.encode(`\r\n--${boundary}${multipartCount === multipart.length - 1 ? '--' : ''}\r\n`));
```

with:

```ts
const bodyBytes =
part.body instanceof Uint8Array
? part.body
: typeof part.body === 'string'
? textEncoder.encode(part.body)
: textEncoder.encode(String(part.body));
dataParts.push(textEncoder.encode('\r\n'), bodyBytes, textEncoder.encode(`\r\n--${boundary}${multipartCount === multipart.length - 1 ? '--' : ''}\r\n`));
```

`Buffer` extends `Uint8Array`, so `part.body instanceof Uint8Array` covers both plain Uint8Arrays (universal) and Buffers (Node). The `String(part.body)` fallback preserves current behavior for any non-string, non-byte inputs (e.g. numbers) rather than throwing — the previous code would also have coerced via template-string equivalence.

- [ ] **Step 4: Typecheck**

```bash
bun run build
```

Expected: no errors.

- [ ] **Step 5: Re-run the MTOM tests**

```bash
bun test test/client.test.ts -t "MTOM" --timeout 15000 2>&1 | tail -15
```

Expected: all six MTOM test runs pass (three tests × two streaming/non-streaming variants).

- [ ] **Step 6: Run the full test suite**

```bash
bun test 2>&1 | tail -5
```

Expected: `166 pass / 2 skip / 0 fail`.

- [ ] **Step 7: Commit**

```bash
git add src/http.ts
git commit -m "$(cat <<'EOF'
fix(http): pass Buffer/Uint8Array attachment bodies through as bytes

The multipart body construction in buildRequest() piped every part
through textEncoder.encode(part.body). TextEncoder.encode accepts a
string and produces UTF-8 bytes — when passed a Buffer, it coerces
via String(buffer), losing all bytes that aren't valid UTF-8 and
truncating at the first null byte. Binary attachments (e.g. PNG) got
corrupted on the way to the wire.

Fix: inspect part.body and pass it through untouched when it's
already a Uint8Array (Buffer inherits from Uint8Array so Node
callers still work). Only encode when body is a string.

Unblocks 'should send binary attachments using XOP + MTOM' which
asserts the raw PNG bytes make it through to the multipart body.

Refs #46.
EOF
)"
```

Expected: commit succeeds.

---

## Task 7: Final verification and PR preparation

- [ ] **Step 1: Full test suite**

```bash
bun test 2>&1 | tail -10
```

Expected: `166 pass / 2 skip / 0 fail`. If the count differs, stop and investigate.

- [ ] **Step 2: Typecheck**

```bash
bun run build
```

Expected: no errors.

- [ ] **Step 3: Lint**

```bash
bun run lint
```

Expected: no new errors. Pre-existing warnings are fine (compare against the pre-commit baseline if unsure).

- [ ] **Step 4: Confirm commit log is clean**

```bash
git log --oneline master..HEAD
```

Expected output (4 or 5 commits depending on whether Task 6 ran):

```
<sha> fix(http): pass Buffer/Uint8Array attachment bodies through as bytes [conditional]
<sha> test(client): unskip integration tests now that root causes are fixed
<sha> test(client): rewrite broken XML-strings tests to verify _xml substitution
<sha> test(client): replace bare assert() with assert.ok() for ESM compat
<sha> fix(http): don't double-invoke callback when user callback throws
<sha> docs(specs): design for unblocking skipped client.test.ts integration tests
```

(The docs commit from the brainstorming phase is also on-branch; that's expected.)

- [ ] **Step 5: Push and open PR**

```bash
git push -u origin 46-7-integration-tests-in-clienttestts-skipped-during-bun-migration-hangstimeouts
gh pr create --title "fix: unblock 7 skipped integration tests in client.test.ts" --body "$(cat <<'EOF'
## Summary

- Fixes double-callback bug in `src/http.ts` where the `.catch` after `.then` re-invoked the user callback if the callback itself threw. Switched to `.then(onFulfilled, onRejected)` so `onRejected` only sees fetch-layer rejections. Production-affecting fix, not just a test unblocker.
- Replaces seven bare `assert(x)` sites with `assert.ok(x)` — the namespace object from `import * as assert from 'node:assert'` isn't callable under ESM, so those sites threw `TypeError` (fast-failing at the test body or silently masking `done()` inside callbacks).
- Rewrites the two `should allow passing in XML strings` tests. They were phantom-passing in upstream node-soap via a chained `.close(() => done())` that fired `done` before the SOAP callback. Rewrite verifies what `_xml` actually does: substitute the raw string into `client.lastRequest`.
- Unskips seven tests (eight `it.skip` sites; `should allow passing in XML strings` has sync + async variants). Each runs twice for streaming and non-streaming variants → 16 new passing tests.

Final count: **166 pass / 2 skip / 0 fail** (the two remaining skips are the pre-existing out-of-scope sites on lines 2150 and 2231).

Related: #43 (already fixed — this issue can now close).

Closes #46.

## Test plan

- [ ] `bun test` — 166 pass / 2 skip / 0 fail
- [ ] `bun run build` — no errors
- [ ] `bun run lint` — no new errors
- [ ] CI passes on this branch
EOF
)"
```

Expected: PR URL returned. Paste it at the end of your summary.

- [ ] **Step 6: Report completion**

Summarize in your final message:
- Final test count (166 pass / 2 skip / 0 fail).
- Whether Task 6 ran (yes means MTOM encoding bug was real and fixed; no means MTOM tests passed under just the src/http.ts + test-layer fixes).
- PR URL.

---

## Self-review (done during planning, not by the executor)

**Spec coverage**

- Root cause 1 (`src/http.ts` double-callback) → Task 2.
- Root cause 2 (bare `assert(x)`) → Task 3.
- Root cause 3 (broken XML-strings tests) → Task 4.
- Unskipping → Task 5.
- Conditional MTOM buffer-encoding → Task 6.
- Verification, commit order, PR prep → Task 7.
- Out-of-scope items (lines 2150, 2231) → verified in Task 5 Step 1 as staying skipped.

All spec sections map to tasks.

**Placeholder scan**

No TBD, TODO, "implement later", "add appropriate error handling", or similar. Every code step shows full code. Every command shows expected output.

**Type consistency**

- `client.lastRequest` is used in both rewritten tests and is a `string | undefined` (per `src/client.ts:66`). The `.includes(xmlStr)` call is valid only if `lastRequest` is set before the callback fires — which it is, because `src/client.ts:523` assigns `this.lastRequest = xml` before invoking the HTTP client. Safe.
- `part.body instanceof Uint8Array` in Task 6 — `Buffer` inherits from `Uint8Array`, verified. Safe.
- The `localBaseUrl` rename in the async XML-strings rewrite doesn't shadow or alias the outer `baseUrl`. Safe.

No inconsistencies.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Markdown formatting/lint issues need cleanup before merge.

CI already flags this file, and static analysis also reports MD038/MD040 violations. Please run formatter/lint and add fence languages (e.g., bash, ts, text) where missing.

🧰 Tools
🪛 GitHub Actions: PR Build

[warning] 1-1: Prettier reported code style issues (file not formatted): plans/2026-04-16-fix-skipped-client-tests.md

🪛 LanguageTool

[style] ~1211-~1211: Three successive sentences begin with the same word. Consider rewording the sentence or use a thesaurus to find a synonym.
Context: ... cause 2 (bare assert(x)) → Task 3. - Root cause 3 (broken XML-strings tests) → Ta...

(ENGLISH_WORD_REPEAT_BEGINNING_RULE)

🪛 markdownlint-cli2 (0.22.0)

[warning] 85-85: Spaces inside code span elements

(MD038, no-space-in-code)


[warning] 86-86: Spaces inside code span elements

(MD038, no-space-in-code)


[warning] 343-343: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


[warning] 369-369: Spaces inside code span elements

(MD038, no-space-in-code)


[warning] 369-369: Spaces inside code span elements

(MD038, no-space-in-code)


[warning] 549-549: Spaces inside code span elements

(MD038, no-space-in-code)


[warning] 550-550: Spaces inside code span elements

(MD038, no-space-in-code)


[warning] 646-646: Spaces inside code span elements

(MD038, no-space-in-code)


[warning] 647-647: Spaces inside code span elements

(MD038, no-space-in-code)


[warning] 791-791: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


[warning] 934-934: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


[warning] 1155-1155: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plans/2026-04-16-fix-skipped-client-tests.md` around lines 1 - 1229, The plan
file has Markdown lint failures (MD038/MD040): run the project's
formatter/linter (e.g., bun run lint and/or prettier --write) and add explicit
fence languages to all fenced code blocks in
plans/2026-04-16-fix-skipped-client-tests.md (use bash for shell commands, ts
for TypeScript blocks, text where appropriate), and ensure fenced blocks follow
markdownlint spacing rules (blank lines before/after fences) so MD038/MD040 are
resolved; re-run the linter to verify no Markdown violations and commit the
fixes referencing the file and MD038/MD040.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed partially in 3911ae3:

  • MD040 (fenced blocks without language): fixed. Tagged four bare fences as text — they're grep-output and git-log examples.
  • MD038 (spaces inside code spans): leaving as-is. Flagged on six Expected first line: + backtick-wrapped-indented-source lines. The indentation inside those spans is the information being conveyed (the reader uses it to confirm they're looking at the right source region). Restructuring into prose or fenced blocks costs more clarity than the warnings cost. Not CI-blocking (project CI uses prettier, not markdownlint).

prettier --check passes across the repo.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@evans-sam, thanks for the explanation. The MD040 fix is solid, and the reasoning for leaving the MD038 warnings is sound — preserving the leading whitespace inside those ` Expected first line: `<indented source>` spans is genuinely more readable than flattening them into prose or wrapping each in a fenced block. Since prettier passes and markdownlint isn't CI-gating, that's a reasonable call.

( ˆ_ˆ)


🧠 Learnings used
Learnt from: evans-sam
Repo: evans-sam/fetch-soap PR: 47
File: test/header-rely-on-xml.test.ts:0-0
Timestamp: 2026-04-17T03:06:41.725Z
Learning: In `test/header-rely-on-xml.test.ts`, the `registerUser` callback intentionally ignores its `err` parameter (renamed `_err`) because the mock HTTP response is a minimal `<soap:Body><Response>Hello</Response></soap:Body>` that does not match the operation's output schema. Response-parsing therefore always emits an expected error, but `capturedHeaders` is already populated by the mock HTTP client before parsing occurs, so the test's header-capture assertions are valid and `if (err) return done(err)` would incorrectly short-circuit them.

Learnt from: evans-sam
Repo: evans-sam/fetch-soap PR: 47
File: plans/2026-04-16-bun-migration.md:1175-1183
Timestamp: 2026-04-17T03:06:25.328Z
Learning: In evans-sam/fetch-soap planning/spec markdown documents, the quoting style inside `plans/` (and similarly intended in `specs/`) may use nested fenced code blocks: outer fences with four backticks (````...````) that wrap inner Markdown fenced blocks with three backticks. Treat this as deliberate markdown-in-markdown quoting (e.g., when demonstrating what to include in CONTRIBUTING.md) and do not raise review warnings about stray or unclosed fences when the nesting is consistent and properly terminated.

Comment thread plans/2026-04-16-fix-skipped-client-tests.md Outdated
Comment thread specs/2026-04-16-fix-skipped-client-tests-design.md Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
plans/2026-04-16-fix-skipped-client-tests.md (2)

87-88: ⚠️ Potential issue | 🟡 Minor

Resolve remaining markdownlint violations (MD038/MD040).

Line 346 (and similar blocks) still has fenced code blocks without a language, and Line 87-style inline code spans still include leading/trailing spaces. These warnings are already known and should be cleaned up in this plan file to keep lint green.

Also applies to: 346-347, 372-373, 553-554, 644-645, 785-786, 928-929, 1146-1147

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plans/2026-04-16-fix-skipped-client-tests.md` around lines 87 - 88, The
markdown file has MD038/MD040 violations: inline code spans include
leading/trailing spaces (e.g., change `    const responsePromise =
fetchFn(options.url, fetchOptions)` to `const responsePromise =
fetchFn(options.url, fetchOptions)`) and fenced code blocks lack a language
identifier; update each inline code span to remove surrounding spaces and add
appropriate language tags (e.g., ```js) to the fenced blocks at the reported
locations (87, 346-347, 372-373, 553-554, 644-645, 785-786, 928-929, 1146-1147)
so the linter stops flagging MD038/MD040.

57-60: ⚠️ Potential issue | 🟡 Minor

Update stale expected test totals to current PR reality.

Line 57 and the other checkpoints still instruct executors to expect 150/18/0 and 166/2/0, which conflicts with the PR’s reported current run (458 pass / 2 skip / 0 fail). Please normalize these expected outputs so verification steps don’t create false alarms.

Also applies to: 284-285, 504-505, 731-732, 941-942, 1080-1081, 1120-1121, 1169-1178

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plans/2026-04-16-fix-skipped-client-tests.md` around lines 57 - 60, Update
the stale expected test totals strings: replace occurrences of "150 pass / 18
skip / 0 fail" and "166 pass / 2 skip / 0 fail" with the current PR run value
"458 pass / 2 skip / 0 fail" (apply the same normalization at every checkpoint
referenced, e.g., the other occurrences around the document noted in the
comment). Search for the literal strings "150 pass / 18 skip / 0 fail" and "166
pass / 2 skip / 0 fail" and update them to "458 pass / 2 skip / 0 fail" so
verification steps match the PR's reported test totals.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@plans/2026-04-16-fix-skipped-client-tests.md`:
- Around line 87-88: The markdown file has MD038/MD040 violations: inline code
spans include leading/trailing spaces (e.g., change `    const responsePromise =
fetchFn(options.url, fetchOptions)` to `const responsePromise =
fetchFn(options.url, fetchOptions)`) and fenced code blocks lack a language
identifier; update each inline code span to remove surrounding spaces and add
appropriate language tags (e.g., ```js) to the fenced blocks at the reported
locations (87, 346-347, 372-373, 553-554, 644-645, 785-786, 928-929, 1146-1147)
so the linter stops flagging MD038/MD040.
- Around line 57-60: Update the stale expected test totals strings: replace
occurrences of "150 pass / 18 skip / 0 fail" and "166 pass / 2 skip / 0 fail"
with the current PR run value "458 pass / 2 skip / 0 fail" (apply the same
normalization at every checkpoint referenced, e.g., the other occurrences around
the document noted in the comment). Search for the literal strings "150 pass /
18 skip / 0 fail" and "166 pass / 2 skip / 0 fail" and update them to "458 pass
/ 2 skip / 0 fail" so verification steps match the PR's reported test totals.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 07333385-e7ac-4f94-91ec-26b5afdd4a9e

📥 Commits

Reviewing files that changed from the base of the PR and between cf92720 and 2daa266.

📒 Files selected for processing (3)
  • plans/2026-04-16-fix-skipped-client-tests.md
  • specs/2026-04-16-fix-skipped-client-tests-design.md
  • test/client.test.ts
✅ Files skipped from review due to trivial changes (1)
  • specs/2026-04-16-fix-skipped-client-tests-design.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/client.test.ts

CodeRabbit feedback:

1. Plan referenced "150 pass / 18 skip" baseline and "166 pass / 2
   skip" final — those were client.test.ts-only counts from the
   migration commit. 'bun test' runs all 17 test files, so the real
   aggregates are 442 (baseline) and 458 (final). Update all
   references.

2. Same issue in the spec's verification section (166 -> 458).

3. Plan had four fenced code blocks without a language, flagged
   MD040 by markdownlint. Tag them 'text' (shell-output-style
   blocks listing grep results and commit SHAs).

MD038 (leading spaces inside backtick spans) flagged on six
"Expected first line: \`    X\`" entries — the indentation IS the
information being conveyed (it's literally the first/last line as
it appears in the source). Restructuring would cost prose clarity.
Leaving those as-is; not CI-blocking.

Refs #46.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
plans/2026-04-16-fix-skipped-client-tests.md (1)

87-88: ⚠️ Potential issue | 🟡 Minor

Fix markdownlint MD038 violations in inline code spans

At Line 87, Line 88, Line 372, Line 553, Line 554, Line 644, and Line 645, inline code spans include leading/trailing spaces (e.g., indentation inside backticks), which triggers MD038. Remove inner-edge spaces or move those samples to fenced blocks to keep lint green.

Also applies to: 372-372, 553-554, 644-645

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plans/2026-04-16-fix-skipped-client-tests.md` around lines 87 - 88, Remove
the leading/trailing spaces inside inline code spans that trigger markdownlint
MD038 by editing the offending inline snippets (for example the backticked
`const responsePromise = fetchFn(options.url, fetchOptions)` and adjacent `});`
sample) to have no inner-edge spaces, or convert the multi-line/indented
examples at the other occurrences into fenced code blocks so the examples remain
formatted without inner backtick padding; apply the same change to the other
reported inline spans (the duplicates referenced) to clear all MD038 violations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@plans/2026-04-16-fix-skipped-client-tests.md`:
- Around line 87-88: Remove the leading/trailing spaces inside inline code spans
that trigger markdownlint MD038 by editing the offending inline snippets (for
example the backticked `const responsePromise = fetchFn(options.url,
fetchOptions)` and adjacent `});` sample) to have no inner-edge spaces, or
convert the multi-line/indented examples at the other occurrences into fenced
code blocks so the examples remain formatted without inner backtick padding;
apply the same change to the other reported inline spans (the duplicates
referenced) to clear all MD038 violations.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f508e920-3151-4f37-98ec-7500a63da810

📥 Commits

Reviewing files that changed from the base of the PR and between 2daa266 and 3911ae3.

📒 Files selected for processing (2)
  • plans/2026-04-16-fix-skipped-client-tests.md
  • specs/2026-04-16-fix-skipped-client-tests-design.md
✅ Files skipped from review due to trivial changes (1)
  • specs/2026-04-16-fix-skipped-client-tests-design.md

CodeRabbit caught a real leak: the rewritten sync XML-strings test
only called server.close() on the success path. If createClient or
MyOperation surfaced an error, assert.ifError would throw before
reaching server.close(), leaving the server listening. The async
variant already routes through .catch which calls server.close().

Add a finish(err?) helper that always closes the server before
calling done. All error branches (createClient err, MyOperation err,
assertion failure on lastRequest content) route through it.

Refs #46.
@evans-sam
Copy link
Copy Markdown
Owner Author

Latest CodeRabbit pass: addressed the one genuinely new finding in ffd6ad5.

Pushed fix (ffd6ad5): Sync XML-strings test now closes the server on all error paths via a finish(err?) helper. Previously only the success path called server.close(), leaving a leak if createClient or MyOperation errored. The async variant already routed through .catch.

Stale-state warnings (ignored):

  • "Prettier check is currently failing for test/client.test.ts" — was true at cf92720, fixed in 2daa266. Current prettier --check passes.

Duplicates of already-addressed items:

  • MD038/MD040 in plans doc — addressed in 3911ae3 (MD040 fixed; MD038 left intentionally with reasoning in commit message).
  • Stale 150/166 counts in plan and spec — fixed in 3911ae3.

Out of scope:

Final state: 458 pass / 2 skip / 0 fail. Prettier clean. Lint clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 integration tests in client.test.ts skipped during Bun migration (hangs/timeouts)

1 participant