Skip to content

Fix: make the web build bundle and run in a browser - #614

Open
AmaadMartin wants to merge 5 commits into
mainfrom
fix/web-build-parseable-and-node-free
Open

Fix: make the web build bundle and run in a browser#614
AmaadMartin wants to merge 5 commits into
mainfrom
fix/web-build-parseable-and-node-free

Conversation

@AmaadMartin

@AmaadMartin AmaadMartin commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

  1. Link to an existing issue (if applicable):

Closes: #608
Closes: #609
Closes: #611
Closes: #612
Related: #607

  1. Or, if no issue exists, describe the change:

Problem: @google/adk advertises browser support, but core/dist/web cannot be consumed by a browser bundler. Bundling dist/web/index_web.js with --platform=browser produced 346 errors, one of which was a syntax error in a shipped file, so no bundler configuration could work around it. Four separate defects:

  1. models/apigee_llm.js did not parse. The browser target named chrome58/safari11, which predate native async generators, so esbuild downlevelled yield* super.generateContentAsync(...) into an __asyncGenerator closure — where super is not valid. Unavoidable via LlmAgent → models/registry → apigee_llm.
  2. Every file in dist/web carried the Node createRequire banner, because it was gated on format === 'esm' and the web build is also ESM. 187 files imported module; none called require().
  3. winston and node:async_hooks were imported by the web build. A shim for node:async_hooks already existed but was gated behind --bundle, which npm run build does not pass, so it never reached a published artifact.
  4. common.ts had accumulated exports reaching node:fs, node:dns, adm-zip, google-auth-library and @google-cloud/vertexai, and index_web.ts re-exports it wholesale.

Solution:

  1. Raise the browser target to chrome63/safari12, the first versions with native async generators. Verified each target individually: chrome58 and safari11 downlevel and break, chrome63 and safari12 do not.
  2. Gate the createRequire banner on platform === 'node' as well as format. dist/esm keeps it, dist/web does not.
  3. Bundle the web target always. esbuild rejects alias without bundling, so this is what makes the shim mechanism work at all. The emitted filename follows the entry, so "browser": "./dist/web/index_web.js" still resolves. Adds utils/winston_shim.ts, implementing only the surface utils/logger.ts uses and writing to the console — that is what a browser log transport does.
  4. Move the Node-only exports from common.ts to index.ts. This follows the layout already in place: index.ts holds the Node-only exports plus export * from './common.js', and index_web.ts is common.js alone, so common.ts is already intended to be the browser-safe surface. The moved set was derived, not chosen — all 199 source modules were bundled for the browser and the ones reaching a Node-only dependency recorded. list_skills_tool, load_skill_tool and search_skills_tool were checked individually and are browser-safe, so they stay.

Result: dist/web/index_web.js bundles for --platform=browser with zero errors, down from 346, and the bundle runs.

bundled for --platform=browser before after
dist/web/index_web.js 346 errors 0
Unexpected "super" 1 0
Could not resolve "module" 147 0

The Node public API is unchanged. dist/esm/index.js exports the same 206 symbols before and after, verified by diffing the resolved export lists from a build of origin/main against this branch.

Testing Plan

Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

tests/integration/build_setup/web_build_test.ts is new; the build output had no test coverage at all, which is how all four defects shipped. Eight tests:

  • dist/web contains no createRequire banner, and dist/esm still does — the second guards against "fixing" the browser build by deleting the banner outright
  • every emitted file parses (esbuild.transform)
  • the path in the browser field exists
  • the entire web entry bundles for --platform=browser, then is imported and driven: LlmAgent, SequentialAgent, ParallelAgent, LoopAgent, FunctionTool, Runner, InMemorySessionService and BaseLlm are all present; an LlmAgent is constructed with a FunctionTool, a session is created, and the tool is executed
  • the Node-only surface (loadAllSkillsInDir, OpenAPIToolset, UnsafeLocalCodeExecutor, GcsArtifactService, VertexAiMemoryBankService, loadWebPage) is absent from the browser entry

Ran locally:

unit:core + unit:dev    2579 passed, 1 failed
                        (dev/test/cli/cli_create_test.ts — reads real gcloud
                        config, fails on main too, unrelated)
integration (new file)  8 passed
npm run docs:check      clean (typedoc --treatWarningsAsErrors)
prettier --check        clean
eslint                  clean

Mutation check (proving the tests can fail): reverting only the common.ts hunk and rebuilding makes the bundle step fail with 173 unresolved Node importsnode:dns/promises, node:net, node:fs/promises, node:path, and fs via @google-cloud/vertexai. vitest exits 1 and reports Test Files 1 failed. Reverting only the build.js hunk fails 6 of 8, naming models/apigee_llm.js as unparseable and listing all 198 bannered files.

Manual End-to-End (E2E) Tests:

npm ci && npm run build
npx esbuild core/dist/web/index_web.js --bundle --platform=browser \
  --format=esm --target=chrome138 --outfile=/tmp/adk.mjs   # 0 errors

node --input-type=module -e "
const adk = await import('/tmp/adk.mjs');
const tool = new adk.FunctionTool({name:'add', description:'adds',
  parameters:{type:'object',properties:{a:{type:'number'},b:{type:'number'}},required:['a','b']},
  execute: async ({a,b}) => ({sum:a+b})});
const agent = new adk.LlmAgent({name:'browser_agent', model:'gemini-2.0-flash',
  instruction:'You are a calculator.', tools:[tool]});
console.log(agent.name, agent.tools.length,
  JSON.stringify(await tool.runAsync({args:{a:2,b:3}, context:undefined})));
"
# browser_agent 1 {"sum":5}

Emitted bundle is 166 KB.

Checklist

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.

Notes for the reviewer

Two defects in the browser build output, both invisible to the existing
suite because it runs against src rather than the built artifacts.

1. `models/apigee_llm.js` did not parse. The browser target named
   chrome58/safari11, which predate native async generators, so esbuild
   downlevelled `yield* super.generateContentAsync(...)` into an
   __asyncGenerator closure and emitted `super` inside it. Raising the
   target to chrome63/safari12 keeps async generators native. This was a
   hard blocker: LlmAgent -> models/registry -> apigee_llm, so no browser
   consumer could avoid it, and no bundler configuration can parse invalid
   syntax.

2. Every file in dist/web carried the Node `createRequire` banner, because
   the banner was gated on `format === 'esm'` and the web build is also
   ESM. 187 files imported `module`; none ever called require().

Bundling dist/web/agents/llm_agent.js for the browser goes from 86 errors
to 21, and dist/web/index_web.js from 346 to 198. The remainder is winston
and node:async_hooks, tracked separately.

Adds a regression test over the build output, which is currently unguarded.
Verified it fails without this change, reporting all 198 bannered files and
naming models/apigee_llm.js as unparseable.

Fixes #608
Fixes #609
Part of #607
Comment thread core/build.js Outdated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Do we want to make bundle always true for web?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good question — I tried it, and the short answer is that it doesn't get us there on its own and it costs us three things.

What bundle: true for web fixes. It makes alias usable. esbuild rejects alias outright without bundling (Cannot use "alias" without "bundle"), which is why the existing node:async_hooks shim is gated on bundle and therefore never reaches published output. With bundling on, that shim applies, and adding a winston alias clears winston too.

What it doesn't fix. packages: 'external' keeps node_modules external, so after bundling and both shims, dist/web/index.js still imports:

node:fs/promises, node:path, node:net, node:dns/promises,
adm-zip, google-auth-library

Those come from the barrel pulling skills/loader (#612) and from node-only packages. So bundling alone doesn't produce a browser-usable artifact.

What it costs.

  1. dist/web/index_web.js stops existing — output becomes dist/web/index.js — so "browser": "./dist/web/index_web.js" in package.json points at a missing file.
  2. Deep imports die. dist/web/agents/llm_agent.js and friends disappear, and importing deep modules is currently the only way to consume the web build at all, precisely because the barrel is unbundleable. It would remove today's workaround before the replacement works.
  3. minify: bundle and sourcemap: bundle are tied to the same flag, so the web build silently becomes minified.

So: I think bundling is likely part of the eventual answer for #611, but as a package it's a bigger change with its own regressions and it needs #612 resolved to actually pay off. I'd rather not fold it into this PR, which is deliberately scoped to the two mechanical defects (unparseable file, Node banner) — that keeps this one easy to reason about and revert.

I've written the four options up on #611, including this one with the measurements above. Happy to take whichever you prefer as a follow-up. My own preference is dropping the winston dependency instead: utils/logger.ts uses it for a level filter and a printf, which is about 40 lines of console code, and that removes the problem at the source for every platform without changing the build shape at all.

One correction I owe you while we're here: my original writeup on #611 claimed the && bundle gate was an oversight and proposed removing it. That was wrong — it's load-bearing, for the reason above. I've posted a retraction on the issue.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done — dist/web/index_web.js now bundles for --platform=browser with zero errors, down from 346, and the test drives the whole thing.

You were right about bundling, and my earlier reply was wrong on the substance. I said it "doesn't get us there on its own and costs three things". Two of those three evaporate once the barrel is fixed:

  • the missing index_web.js was self-inflicted — the outfile was hardcoded to index.js; making it follow the entry keeps the browser field resolving
  • losing deep imports only mattered because the barrel was unbundleable; once it bundles, nobody needs them

So the web target is now always bundled, which is what makes alias usable and lets the node:async_hooks shim actually reach a published artifact for the first time.

What it took beyond that:

  • a winston shim (utils/winston_shim.ts) — utils/logger.ts drags winston into every module that logs, and winston needs os/fs/util/zlib/http
  • moving the Node-only exports out of common.ts

On the second one: I didn't hand-pick that list. I bundled all 199 source modules for the browser and recorded which reach a Node-only dependency, then moved exactly those. It turned out to be a small set — skills/loader, skills/gcp_skill_registry, tools/openapi_tool/*, tools/skill/{load_skill_resource,skill_toolset}, tools/load_web_page, and memory/vertex_ai_memory_bank_service. I checked list_skills_tool, load_skill_tool and search_skills_tool individually — they're browser-safe, so they stayed.

This also turned out not to be a new structure. index.ts already holds the Node-only exports plus export * from './common.js', and index_web.ts is common.js alone — so common.ts was already meant to be the browser-safe surface, and things had just leaked into it. The moved exports land next to the ones already in index.ts.

The Node public API is unchanged: 206 exports from dist/esm/index.js before and after, verified by diffing resolved export lists against a build of origin/main.

The test now covers the entire bundle, per your comment on the other thread — it bundles index_web.js, imports it, and constructs an LlmAgent with a FunctionTool, creates a session and executes the tool. Full detail in the PR description.

Two things I'd flag rather than bury:

  1. dist/web is now a single minified index_web.js instead of a file tree, so @google/adk/dist/web/agents/llm_agent.js no longer resolves. The exports map doesn't expose those paths anyway (package.json exports has no browser condition, so bundlers resolve the Node build and the browser field is ignored #610), and they were only a workaround for this exact bug, but it is a real change.
  2. The browser entry no longer exports the Node-only surface. Nothing that previously worked is lost — none of it could run in a browser — but it is a narrower public surface for the web build specifically.

Happy to split the barrel move into its own commit or PR if you'd rather review it separately; it's already an isolated commit (a6192c9) so it lifts out cleanly.

@kalenkevich kalenkevich Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we create another test that will spin up real html page that will include compiled ADK with some simple test to see that everything works and nothing is really broken?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, and I've pushed a version of this in d84b1ce.

The new web build output is usable by a browser bundler block takes entry points from dist/web, bundles them with --platform=browser, imports the result, and exercises it for real:

const tool = new FunctionTool({name: 'add', ..., execute: async ({a, b}) => ({sum: a + b})});
expect(isFunctionTool(tool)).toBe(true);
expect(tool._getDeclaration()?.parameters).toEqual({...});   // what a model actually receives
expect(await tool.runAsync({args: {a: 2, b: 3}, context: undefined})).toEqual({sum: 5});

So it's compiled browser output being constructed and executed, not just parsed. I verified all three new tests fail against an unpatched build.

Two things I want to flag rather than quietly decide:

1. The entry-point list is short, and that's the real finding. Right now only events/event.js and tools/function_tool.js bundle with zero errors. Here's the current state with this PR applied:

entry bundle errors
events/event.js 0
tools/function_tool.js 0
models/base_llm.js 1
sessions/in_memory_session_service.js 20
agents/llm_agent.js 21
runner/runner.js 21
index_web.js 198

The 20–21 are winston + node:async_hooks (#611); the 198 adds the barrel's skills/loader (#612). So a test that loads everything can't pass until those land — I've left a comment in the file saying to add agents/llm_agent.js and runner/runner.js as they become bundleable, since each is one dependency away.

2. It's not literally an HTML page in a browser, and that was a deliberate call. The repo has no browser test tooling today — no puppeteer, playwright or jsdom, and every vitest project is environment: 'node'. A true headless-browser test means adding a browser dependency that runs on all three CI OSes, which felt like your call rather than something to slip into a bugfix PR.

What's committed gets most of the value with no new dependency: it proves the compiled artifact bundles, imports and behaves. What it can't catch is anything genuinely browser-specific — a real window/DOM dependency, or code that only breaks under a browser's module loader.

If you'd like the real thing, I'm happy to add it. My suggestion would be to do it once #611 and #612 land, so the page can load the actual barrel and assert something meaningful end to end, and to open it as its own PR given it's a new devDependency. Say the word and I'll file the issue.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Understood — done. The test now bundles the entire web entry rather than a subset.

I'd scoped it to two modules because that was all that bundled cleanly at the time. That was treating the symptom, so I fixed the remaining two blockers instead (winston + node:async_hooks, and the Node-only exports leaking into common.ts) — details on the other thread and in the PR description.

tests/integration/build_setup/web_build_test.ts now does:

await esbuild.build({
  entryPoints: [WEB_ENTRY],          // core/dist/web/index_web.js — the whole bundle
  bundle: true, format: 'esm', platform: 'browser', target: 'chrome138',
});

then imports the result and drives the public API:

  • LlmAgent, SequentialAgent, ParallelAgent, LoopAgent, FunctionTool, Runner, InMemorySessionService, BaseLlm are all present and callable
  • an LlmAgent is constructed with a FunctionTool, a session is created, and the tool is executed — {sum: 5}
  • the Node-only surface (loadAllSkillsInDir, OpenAPIToolset, UnsafeLocalCodeExecutor, GcsArtifactService, VertexAiMemoryBankService, loadWebPage) is asserted absent from the browser entry, so the barrel can't silently re-acquire a node:fs dependency

The bundle step is in beforeAll, so if the web build ever stops bundling the whole file fails rather than skipping — I checked that specifically: reverting the common.ts hunk gives 173 unresolved Node imports, vitest exits 1, Test Files 1 failed.

On the "real html page" part specifically: this stops just short of a browser engine. It proves the bundle builds, imports and behaves, but it can't catch something genuinely browser-only — a real window/DOM dependency, or a failure specific to a browser's module loader. Closing that gap means a headless-browser devDependency (there's no puppeteer/playwright/jsdom in the repo today, and every vitest project is environment: 'node'), which felt like your call rather than mine to make inside a bugfix PR.

Now that the bundle actually works, that test would be straightforward — load index_web.js in a page and run the same agent construction. Happy to add it here if you want it, or file it as a follow-up. Just say which.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

You're right, and this is now built.

tests/integration/build_setup/web_app/ is a real page — message list, prompt input, send button — that imports core/dist/web/index_web.js directly with no build step of its own. web_app_test.ts drives it in headless Chromium via Playwright:

✓ loads the shipped bundle without a module or runtime error
✓ shows the user message and the agent reply after clicking send
✓ runs a second turn on the same session

It types into the input, clicks send, waits for li[data-author="agent"], and asserts the rendered list is exactly:

[{author: 'user',  text: 'hello agent'},
 {author: 'agent', text: 'Hello from the agent'}]

The model is a BaseLlm subclass returning a canned response — the same shape as the mock in tests/integration/build_setup/ts_esm/agent.ts. pageerror and console errors are collected and asserted empty, so a DOM reference or a module-loading failure fails the test rather than passing quietly.

You were right about the synthetic-ness, and I'd add a specific reason my previous test could not have caught real issues: it imported browser-targeted code into Node. Anything that only breaks under a browser's module loader, or touches window/document, would have passed it. That gap was the whole point of the test.

One thing this surfaced that I'd missed. dist/web/index_web.js was bundled but still had packages: 'external', so it emitted bare specifiers (@google/genai, zod, lodash-es) that a browser cannot resolve — the page could not have loaded it. The web target now bundles its dependencies and is genuinely self-contained at 972 KB. Node keeps packages: 'external' unchanged. That is also what makes your other comment satisfiable: the test no longer bundles anything.

Agreed on expanding from here — streaming, tool calls and multi-agent are the obvious next ones, and the fixture is set up so each is a new page interaction rather than new plumbing.

Addresses review feedback asking for a test that loads compiled ADK and
checks it actually works.

Parsing is necessary but not sufficient: it would not catch output that
bundles and then throws on import, or a primitive that is broken once
compiled for the browser. This bundles entry points from dist/web with
--platform=browser and runs them — constructing a FunctionTool, checking
the declaration a model would receive, and executing it.

The entry-point list is short because most of dist/web still cannot be
bundled: the barrel pulls skills/loader (node:fs, node:path) and the
logger pulls winston. agents/llm_agent.js and runner/runner.js are the
two worth adding next and are one dependency away each.

Verified all three new tests fail against an unpatched build.
* and `runner/runner.js` are the two worth having next, and both are one
* dependency away.
*/
describe('web build output is usable by a browser bundler', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No, we need to test entire ADK TS bundle for web.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed — the test now bundles and runs the entire dist/web/index_web.js, not a subset. See the thread above for what it took (the two remaining blockers had to be fixed, which they now are) and the PR description for the full testing plan.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed — and you were right that it was circular. dist/web is the bundle; re-bundling it in the test only exercised my esbuild invocation.

Removed. The browser test now loads core/dist/web/index_web.js straight from a <script type="module"> with no build step.

That only actually works because of a related fix: the web output had packages: 'external', so it still emitted bare specifiers a browser cannot resolve. It is now self-contained, so "already bundled" is true in the sense you meant it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done — moved to tests/integration/build_setup/node_build_test.ts, which covers the ESM and CJS builds:

  • the ESM build still emits the createRequire banner
  • dist/cjs/package.json declares "type": "commonjs"
  • the Node builds still import winston (see below)

web_build_test.ts is now dist/web only.

Follows review feedback asking that the entire ADK TS web bundle be
tested, not a subset. Getting there needed the remaining two defects
fixed rather than deferred.

Bundle the web target always. esbuild rejects `alias` unless bundling,
so the existing node:async_hooks shim never reached published output --
`npm run build` does not pass --bundle. Bundling the web target makes
the alias mechanism work, and the emitted filename now follows the entry
so package.json's "browser" field still resolves.

Add a browser stand-in for winston. utils/logger.ts pulls winston into
every module that logs, and winston needs os/fs/util/zlib/http. The shim
implements only the surface logger.ts uses and writes to the console,
which is what a browser transport does.

Keep Node-only modules out of the web barrel. index.ts already holds the
Node-only exports and index_web.ts is common.ts alone, so common.ts is
meant to be the browser-safe surface -- but exports reaching node:fs,
node:dns, adm-zip, google-auth-library and @google-cloud/vertexai had
leaked into it. Those move to index.ts alongside the ones already there.
The set was derived by bundling all 199 source modules for the browser
and recording which reach a Node-only dependency, not chosen by hand.

core/dist/web/index_web.js now bundles for --platform=browser with zero
errors, down from 346. The bundle imports and drives the public API:
LlmAgent, ParallelAgent, SequentialAgent, LoopAgent, FunctionTool,
Runner, InMemorySessionService and BaseLlm are all present and working.

The Node public API is unchanged -- 206 exports from dist/esm/index.js
before and after, verified by diffing the resolved export lists.

Mutation check: reverting only the common.ts hunk makes the bundle step
fail with 173 unresolved Node imports, vitest exits 1 and reports the
file as failed.
@AmaadMartin AmaadMartin changed the title Fix: make dist/web parseable and free of the Node createRequire banner Fix: make the web build bundle and run in a browser Aug 4, 2026
) as {browser?: string};
expect(pkg.browser).toBeDefined();
const target = path.join(process.cwd(), 'core', pkg.browser!);
await expect(fs.access(target)).resolves.toBeUndefined();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

seems to be a bug. Should it be defined instead?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch — it was confusing even though it happened to pass. fs.access resolves to undefined on success, so .resolves.toBeUndefined() was technically correct, but it reads like an inverted assertion and would silently keep passing if the call were swapped for something else.

Replaced with something that says what it means:

const stat = await fs.stat(target);
expect(stat.isFile()).toBe(true);
expect(stat.size).toBeGreaterThan(0);

That also asserts more than the old version did: the path exists, is a file, and is non-empty.


it('still emits the createRequire banner for the Node ESM build', async () => {
// Guards against fixing the browser build by dropping the banner outright.
const nodeFiles = await collectJsFiles(NODE_ESM_DIST);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

it make sense to create a separate test that will test it against the ESM and CJS builds. So please drop this test from the web test

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done — moved to tests/integration/build_setup/node_build_test.ts, which covers the ESM and CJS builds:

  • the ESM build still emits the createRequire banner
  • dist/cjs/package.json declares "type": "commonjs"
  • the Node builds still import winston (see below)

web_build_test.ts is now dist/web only.

Comment on lines +117 to +125
await esbuild.build({
entryPoints: [WEB_ENTRY],
outfile: bundlePath,
bundle: true,
format: 'esm',
platform: 'browser',
target: 'chrome138',
logLevel: 'silent',
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

we should already test agains the bundled ADK TS. IT does not make sense to bundle it in the test.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed — and you were right that it was circular. dist/web is the bundle; re-bundling it in the test only exercised my esbuild invocation.

Removed. The browser test now loads core/dist/web/index_web.js straight from a <script type="module"> with no build step.

That only actually works because of a related fix: the web output had packages: 'external', so it still emitted bare specifiers a browser cannot resolve. It is now self-contained, so "already bundled" is true in the sense you meant it.

Comment on lines +134 to +206
it('bundles the entire web entry point for the browser', async () => {
const stat = await fs.stat(bundlePath);
expect(stat.size).toBeGreaterThan(0);
});

it('exposes the core primitives once bundled', async () => {
const adk = await import(pathToFileURL(bundlePath).href);
for (const symbol of [
'LlmAgent',
'SequentialAgent',
'ParallelAgent',
'LoopAgent',
'FunctionTool',
'Runner',
'InMemorySessionService',
'BaseLlm',
]) {
expect(typeof adk[symbol], symbol).toBe('function');
}
});

it('excludes the Node-only surface from the browser entry point', async () => {
const adk = await import(pathToFileURL(bundlePath).href);
// These reach node:fs, child_process, google-auth-library or
// @google-cloud/vertexai, and are exported from index.ts for Node only.
for (const symbol of [
'loadAllSkillsInDir',
'OpenAPIToolset',
'UnsafeLocalCodeExecutor',
'GcsArtifactService',
'VertexAiMemoryBankService',
'loadWebPage',
]) {
expect(adk[symbol], symbol).toBeUndefined();
}
});

it('builds a working agent and tool from the bundle', async () => {
const adk = await import(pathToFileURL(bundlePath).href);

const tool = new adk.FunctionTool({
name: 'add',
description: 'adds two numbers',
parameters: {
type: 'object',
properties: {a: {type: 'number'}, b: {type: 'number'}},
required: ['a', 'b'],
},
execute: async ({a, b}: {a: number; b: number}) => ({sum: a + b}),
});

const agent = new adk.LlmAgent({
name: 'browser_agent',
model: 'gemini-2.0-flash',
instruction: 'You are a calculator.',
tools: [tool],
});
expect(agent.name).toBe('browser_agent');
expect(agent.tools).toHaveLength(1);

const sessions = new adk.InMemorySessionService();
const session = await sessions.createSession({
appName: 'browser_app',
userId: 'user',
});
expect(session.id).toBeTruthy();

const result = await tool.runAsync({
args: {a: 2, b: 3},
context: undefined,
});
expect(result).toEqual({sum: 5});
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

That is not what we really want to have. This kind of tests are very synthetic and might not catch real issues.

What I propose is to create a real HTML page that will include complied (and bundled) ADK TS from dist and RUN some REAL browser (headless) test.

For example the HTML page where messages, prompt input and send button. When user puts something in the input and clicks send button, their message should appear in the message list and agent should respond with some message. Test should verify that it is actually working.
The real model response can be mocked with predefined result (in the same way we do that for our integration tests).

So such kind of test will ensure that ADK TS for web compiled and bundled. It can be imported and used in the real web app and it will provide some basic functionality.

Once this is done, we can expand this testcases to test more ADK TS features on web.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

You're right, and this is now built.

tests/integration/build_setup/web_app/ is a real page — message list, prompt input, send button — that imports core/dist/web/index_web.js directly with no build step of its own. web_app_test.ts drives it in headless Chromium via Playwright:

✓ loads the shipped bundle without a module or runtime error
✓ shows the user message and the agent reply after clicking send
✓ runs a second turn on the same session

It types into the input, clicks send, waits for li[data-author="agent"], and asserts the rendered list is exactly:

[{author: 'user',  text: 'hello agent'},
 {author: 'agent', text: 'Hello from the agent'}]

The model is a BaseLlm subclass returning a canned response — the same shape as the mock in tests/integration/build_setup/ts_esm/agent.ts. pageerror and console errors are collected and asserted empty, so a DOM reference or a module-loading failure fails the test rather than passing quietly.

You were right about the synthetic-ness, and I'd add a specific reason my previous test could not have caught real issues: it imported browser-targeted code into Node. Anything that only breaks under a browser's module loader, or touches window/document, would have passed it. That gap was the whole point of the test.

One thing this surfaced that I'd missed. dist/web/index_web.js was bundled but still had packages: 'external', so it emitted bare specifiers (@google/genai, zod, lodash-es) that a browser cannot resolve — the page could not have loaded it. The web target now bundles its dependencies and is genuinely self-contained at 972 KB. Node keeps packages: 'external' unchanged. That is also what makes your other comment satisfiable: the test no longer bundles anything.

Agreed on expanding from here — streaming, tool calls and multi-agent are the obvious next ones, and the fixture is set up so each is a new page interaction rather than new plumbing.

Amaad Martin added 2 commits August 4, 2026 23:02
…eb bundle

Replaces the synthetic bundle test with the real thing, per review.

tests/integration/build_setup/web_app/ is a small chat page -- message
list, prompt input, send button -- that imports core/dist/web/index_web.js
directly, with no build step of its own. web_app_test.ts drives it in
headless Chromium with Playwright: types a message, clicks send, and
asserts the user message and the agent reply both render, then runs a
second turn on the same session. The model is a BaseLlm subclass
returning a canned response, matching the mock in the existing
build_setup fixtures. Page errors and console errors fail the test, so a
DOM or module-loading failure surfaces instead of passing silently.

Making that possible meant the web target had to become genuinely
self-contained. It was bundled but kept `packages: 'external'`, so the
output still imported '@google/genai', 'zod' and friends as bare
specifiers, which a browser cannot resolve. The web target now bundles
its dependencies; Node keeps packages external as before. 972 KB.

Splits the build-output tests as requested: web_build_test.ts covers
dist/web only, and the Node ESM/CJS assertions move to node_build_test.ts.
Drops the assertions that re-bundled dist/web inside the test -- the
shipped artifact is already a bundle, and the browser test now exercises
it directly.

Adds playwright-chromium and a CI step to install the browser.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment