Merge "Fix infinite update loop in gr-autogrow-textarea"
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index aadfe1c..836b93a 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -2293,6 +2293,21 @@
 }
 ----
 
+[[cache-def]]
+== Cache Definitions
+
+Plugins can request a view of the definitions of all registered caches by
+injecting a `DynamicMap<CacheDef<?,?>>`. This will allow plugins to inspect what
+caches are currently registered and what their schema looks like.
+
+[source,java]
+----
+@Inject
+public MyClass(DynamicMap<CacheDef<?, ?>> cacheMap) {
+  // your code
+}
+----
+
 [[secure-store]]
 == SecureStore
 
diff --git a/java/com/google/gerrit/server/cache/CacheModule.java b/java/com/google/gerrit/server/cache/CacheModule.java
index 85c5cc56..f7ed408 100644
--- a/java/com/google/gerrit/server/cache/CacheModule.java
+++ b/java/com/google/gerrit/server/cache/CacheModule.java
@@ -37,6 +37,7 @@
   public static final String PERSISTENT_MODULE = "cache-persistent";
 
   private static final TypeLiteral<Cache<?, ?>> ANY_CACHE = new TypeLiteral<>() {};
+  private static final TypeLiteral<CacheDef<?, ?>> ANY_CACHE_DEF = new TypeLiteral<>() {};
 
   /**
    * Declare a named in-memory cache.
@@ -182,7 +183,7 @@
     @SuppressWarnings("unchecked")
     Key<CacheDef<K, V>> cacheDefKey = (Key<CacheDef<K, V>>) Key.get(cacheDefType, named);
     bind(cacheDefKey).toInstance(m);
-
+    bind(ANY_CACHE_DEF).annotatedWith(Exports.named(name)).to(cacheDefKey);
     m.maximumWeight(1024);
   }
 }
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 39dfba2..ec251d0 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -117,6 +117,7 @@
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.UniversalAuthBackend;
 import com.google.gerrit.server.avatar.AvatarProvider;
+import com.google.gerrit.server.cache.CacheDef;
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.change.AbandonOp;
 import com.google.gerrit.server.change.AccountPatchReviewStore;
@@ -367,6 +368,7 @@
 
     bind(GitReferenceUpdated.class);
     DynamicMap.mapOf(binder(), new TypeLiteral<Cache<?, ?>>() {});
+    DynamicMap.mapOf(binder(), new TypeLiteral<CacheDef<?, ?>>() {});
     DynamicSet.setOf(binder(), CacheRemovalListener.class);
     DynamicMap.mapOf(binder(), CapabilityDefinition.class);
     DynamicMap.mapOf(binder(), PluginProjectPermissionDefinition.class);
diff --git a/javatests/com/google/gerrit/server/cache/CacheFactoryIT.java b/javatests/com/google/gerrit/server/cache/CacheFactoryIT.java
new file mode 100644
index 0000000..593685e
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/CacheFactoryIT.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.Extension;
+import com.google.inject.Inject;
+import java.util.Optional;
+import java.util.stream.StreamSupport;
+import org.junit.Test;
+
+public class CacheFactoryIT extends AbstractDaemonTest {
+  private static final String CACHE_NAME = "dummy-cache";
+
+  @Inject private DynamicMap<CacheDef<?, ?>> cacheDefs;
+
+  @Override
+  public com.google.inject.Module createModule() {
+    return new TestModule();
+  }
+
+  @Test
+  public void newCacheDefIsAvailableInDynamicMap() {
+    assertThat(cacheDefs).isNotNull();
+
+    Optional<? extends CacheDef<?, ?>> def =
+        StreamSupport.stream(cacheDefs.spliterator(), false)
+            .filter(e -> e.getExportName().equals(CACHE_NAME))
+            .map(Extension::get)
+            .findFirst();
+    assertThat(def).isPresent();
+    assertThat(def.get().keyType().getRawType()).isEqualTo(String.class);
+    assertThat(def.get().valueType().getRawType()).isEqualTo(String.class);
+  }
+
+  public static class TestModule extends CacheModule {
+
+    @Override
+    protected void configure() {
+      cache(CACHE_NAME, String.class, String.class);
+    }
+  }
+}
diff --git a/polygerrit-ui/GEMINI.md b/polygerrit-ui/GEMINI.md
index 1dc48ad..015a548 100644
--- a/polygerrit-ui/GEMINI.md
+++ b/polygerrit-ui/GEMINI.md
@@ -23,6 +23,7 @@
 - `npm run eslintfix`: Fix lint errors in all files
 
 **Note**: Imports should NOT have spaces around braces (e.g., `import {css} from 'lit';`, not `import { css } from 'lit';`).
+**Note**: Do not use `_` prefix for private properties or variables.
 
 ## Key Commands
 
diff --git a/polygerrit-ui/app/elements/change/gr-ai-prompt-dialog/gr-ai-prompt-dialog.ts b/polygerrit-ui/app/elements/change/gr-ai-prompt-dialog/gr-ai-prompt-dialog.ts
index 26d6e9d..a990898 100644
--- a/polygerrit-ui/app/elements/change/gr-ai-prompt-dialog/gr-ai-prompt-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-ai-prompt-dialog/gr-ai-prompt-dialog.ts
@@ -313,8 +313,15 @@
   }
 
   private getNumParents() {
-    return this.change?.revisions[this.change.current_revision].commit?.parents
-      .length;
+    if (
+      !this.change ||
+      !this.change.current_revision ||
+      !this.change.revisions
+    ) {
+      return 0;
+    }
+    const revision = this.change.revisions[this.change.current_revision];
+    return revision?.commit?.parents.length ?? 0;
   }
 
   private async loadPatchContent() {
@@ -323,13 +330,11 @@
     const content = await this.restApiService.getPatchContent(
       this.change._number,
       this.patchNum,
-      this.context
+      this.context,
+      () => fireError(this, 'Failed to get patch content')
     );
     this.loading = false;
-    if (!content) {
-      fireError(this, 'Failed to get patch content');
-      return;
-    }
+    if (!content) return;
     this.patchContent = content;
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-ai-prompt-dialog/gr-ai-prompt-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-ai-prompt-dialog/gr-ai-prompt-dialog_test.ts
index 699035e..8165a0f 100644
--- a/polygerrit-ui/app/elements/change/gr-ai-prompt-dialog/gr-ai-prompt-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-ai-prompt-dialog/gr-ai-prompt-dialog_test.ts
@@ -9,12 +9,14 @@
 import {GrAiPromptDialog} from './gr-ai-prompt-dialog';
 import {createParsedChange} from '../../../test/test-data-generators';
 import {CommitId, PatchSetNum} from '../../../api/rest-api';
-import {stubRestApi} from '../../../test/test-utils';
+import {stubRestApi, waitUntil} from '../../../test/test-utils';
 
 suite('gr-ai-prompt-dialog test', () => {
   let element: GrAiPromptDialog;
+  let getPatchContentStub: sinon.SinonStub;
   setup(async () => {
-    stubRestApi('getPatchContent').returns(Promise.resolve('test code'));
+    getPatchContentStub = stubRestApi('getPatchContent');
+    getPatchContentStub.resolves('test code');
     element = await fixture(html`<gr-ai-prompt-dialog></gr-ai-prompt-dialog>`);
     element.change = createParsedChange();
     element.change.revisions['abc'].commit!.parents = [
@@ -142,4 +144,27 @@
         </section>`
     );
   });
+
+  test('handles failed patch content fetch', async () => {
+    getPatchContentStub.callsFake((_c, _p, _ctx, errFn) => {
+      if (errFn) errFn();
+      return Promise.resolve(undefined);
+    });
+    const fireStub = sinon.stub(element, 'dispatchEvent');
+
+    element.open();
+
+    await waitUntil(() => fireStub.called);
+
+    assert.isTrue(fireStub.called);
+    const events = fireStub.args.map(arg => arg[0]);
+    assert.isTrue(
+      events.some(
+        event =>
+          event.type === 'show-error' &&
+          (event as CustomEvent).detail.message ===
+            'Failed to get patch content'
+      )
+    );
+  });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_screenshot_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_screenshot_test.ts
index b561da5..4a700d7 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_screenshot_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_screenshot_test.ts
@@ -294,7 +294,7 @@
 
     try {
       // Wait for all nested components to render
-      await new Promise(resolve => setTimeout(resolve, 500));
+      await new Promise(resolve => setTimeout(resolve, 300));
       await element.updateComplete;
       if (element.fileList) {
         await element.fileList.updateComplete;
@@ -326,7 +326,7 @@
 
     try {
       // Wait for all nested components to render
-      await new Promise(resolve => setTimeout(resolve, 500));
+      await new Promise(resolve => setTimeout(resolve, 300));
       await element.updateComplete;
       if (element.fileList) {
         await element.fileList.updateComplete;
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index c9f2604..207aaf6 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -628,7 +628,7 @@
       element.loading = false;
       await element.updateComplete;
 
-      clock = sinon.useFakeTimers();
+      clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
     });
 
     teardown(() => {
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
index 5bccb07..95df2fac 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
@@ -280,12 +280,11 @@
     if (!this.change || !this.patchNum) return;
     const patchContent = await this.restApiService.getPatchContent(
       this.change._number,
-      this.patchNum
+      this.patchNum,
+      undefined,
+      () => fireError(this, 'Failed to get patch content')
     );
-    if (!patchContent) {
-      fireError(this, 'Failed to get patch content');
-      return;
-    }
+    if (!patchContent) return;
     await copyToClipboard(patchContent, 'patch file content');
     this.handleCloseTap(e);
   }
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
index d71c2af..c107c20 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
@@ -350,7 +350,10 @@
     });
 
     test('handles failed patch content fetch', async () => {
-      getPatchContentStub.resolves(undefined);
+      getPatchContentStub.callsFake((_c, _p, _ctx, errFn) => {
+        if (errFn) errFn();
+        return Promise.resolve(undefined);
+      });
 
       const copyButton = queryAndAssert<GrButton>(
         element,
@@ -362,20 +365,5 @@
       assert.isFalse(copyToClipboardStub.called);
       assert.isTrue(fireStub.called);
     });
-
-    test('handles error during patch content fetch', async () => {
-      const error = new Error('Network error');
-      getPatchContentStub.rejects(error);
-
-      const copyButton = queryAndAssert<GrButton>(
-        element,
-        '#copy-clipboard-button'
-      );
-      copyButton.click();
-
-      await waitUntil(() => getPatchContentStub.called);
-      assert.isFalse(copyToClipboardStub.called);
-      assert.isFalse(fireStub.called);
-    });
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index e499789..7b34016 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -282,6 +282,9 @@
   @state()
   cleanlyMergedOldPaths: string[] = [];
 
+  // Private but used in tests.
+  patchChange?: PatchChange;
+
   private cancelForEachDiff?: () => void;
 
   @state()
@@ -927,6 +930,7 @@
       this.updateDiffPreferences();
     }
     if (changedProperties.has('files')) {
+      this.patchChange = undefined;
       this.filesChanged();
       this.numFilesShown = Math.min(this.files.length, DEFAULT_NUM_FILES_SHOWN);
       this.updateSizeBarLayout();
@@ -1892,11 +1896,12 @@
 
   // Private but used in tests.
   calculatePatchChange(): PatchChange {
+    if (this.patchChange) return this.patchChange;
     const magicFilesExcluded = this.files.filter(
       file => !isMagicPath(file.__path)
     );
 
-    return magicFilesExcluded.reduce((acc, obj) => {
+    this.patchChange = magicFilesExcluded.reduce((acc, obj) => {
       const inserted = obj.lines_inserted ? obj.lines_inserted : 0;
       const deleted = obj.lines_deleted ? obj.lines_deleted : 0;
       const total_size = obj.size && obj.binary ? obj.size : 0;
@@ -1913,6 +1918,7 @@
         total_size: acc.total_size + total_size,
       };
     }, createDefaultPatchChange());
+    return this.patchChange;
   }
 
   private toggleHideAllCommentsAndCodePointers() {
diff --git a/polygerrit-ui/app/elements/change/gr-flows/gr-flows_test.ts b/polygerrit-ui/app/elements/change/gr-flows/gr-flows_test.ts
index 6cba4d9..b8af64a 100644
--- a/polygerrit-ui/app/elements/change/gr-flows/gr-flows_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-flows/gr-flows_test.ts
@@ -59,7 +59,7 @@
   let userModel: UserModel;
 
   setup(async () => {
-    clock = sinon.useFakeTimers();
+    clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
 
     changeModel = testResolver(changeModelToken);
     userModel = testResolver(userModelToken);
diff --git a/polygerrit-ui/app/elements/chat-panel/prompt-box.ts b/polygerrit-ui/app/elements/chat-panel/prompt-box.ts
index bbfd052..4474b4f 100644
--- a/polygerrit-ui/app/elements/chat-panel/prompt-box.ts
+++ b/polygerrit-ui/app/elements/chat-panel/prompt-box.ts
@@ -269,6 +269,8 @@
       color: var(--primary-text-color);
       font: inherit;
       font-size: 16px; /* $font-size-large */
+      /* Explicitly set line-height to ensure consistent height in tests (e.g. 20px). */
+      line-height: 20px;
       border: none;
       outline: none;
       resize: none;
diff --git a/polygerrit-ui/app/elements/chat-panel/prompt-box_test.ts b/polygerrit-ui/app/elements/chat-panel/prompt-box_test.ts
index 00059b1..429bba7 100644
--- a/polygerrit-ui/app/elements/chat-panel/prompt-box_test.ts
+++ b/polygerrit-ui/app/elements/chat-panel/prompt-box_test.ts
@@ -57,7 +57,7 @@
               spellcheck="false"
               aria-label="Ask Gemini"
               placeholder="Enter a prompt here..."
-              style="height: 18px;"
+              style="height: 20px;"
             ></textarea>
           </div>
         </div>
@@ -256,6 +256,6 @@
     textarea.dispatchEvent(new Event('input'));
     await element.updateComplete;
 
-    assert.equal(textarea.style.height, '90px');
+    assert.equal(textarea.style.height, '100px');
   });
 });
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
index 1655d6f..b52c84c 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
@@ -17,7 +17,7 @@
 
   setup(async () => {
     const fakeNow = new Date('Sep 26 2022 12:00:00');
-    sinon.useFakeTimers(fakeNow);
+    sinon.useFakeTimers({now: fakeNow, shouldClearNativeTimers: true});
     element = await fixture<GrHovercardRun>(html`
       <gr-hovercard-run class="hovered"></gr-hovercard-run>
     `);
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
index 3ff3df2..6a4d95b 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -266,13 +266,17 @@
     let urlPromise: MockPromise<string>;
 
     setup(() => {
+      clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
       stubRestApi('addRepoNameToCache');
       urlPromise = mockPromise<string>();
       redirectStub = sinon
         .stub(router, 'redirect')
         .callsFake(urlPromise.resolve);
       router._testOnly_startRouter();
-      clock = sinon.useFakeTimers();
+    });
+
+    teardown(() => {
+      clock.restore();
     });
 
     test('no blockers: normal redirect', async () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
index 7aaf3bb..f8fd10e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
@@ -392,7 +392,9 @@
     });
 
     test('keyboard shortcuts', async () => {
-      clock = sinon.useFakeTimers();
+      clock = sinon.useFakeTimers({
+        toFake: ['Date'],
+      });
       element.changeNum = 42 as NumericChangeId;
       browserModel.setScreenWidth(0);
       element.patchNum = 10 as RevisionPatchSetNum;
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
index f8edb8c..11df7d0 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
@@ -97,7 +97,7 @@
     );
   }
 
-  override updated(changedProperties: PropertyValues) {
+  override willUpdate(changedProperties: PropertyValues) {
     if (changedProperties.has('accounts')) {
       if (
         this.forceFetch &&
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index 61c23d8..165dc03 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -9,6 +9,7 @@
 import {sortComments} from '../../../utils/comment-util';
 import {GrCommentThread} from './gr-comment-thread';
 import {
+  AccountId,
   CommentInfo,
   CommentThread,
   DraftInfo,
@@ -55,14 +56,14 @@
 import {suggestionsServiceToken} from '../../../services/suggestions/suggestions-service';
 
 const c1: CommentInfo = {
-  author: {name: 'Kermit'},
+  author: {name: 'Kermit', _account_id: 1 as AccountId},
   id: 'the-root' as UrlEncodedCommentId,
   message: 'start the conversation',
   updated: '2021-11-01 10:11:12.000000000' as Timestamp,
 };
 
 const c2: CommentInfo = {
-  author: {name: 'Ms Piggy'},
+  author: {name: 'Ms Piggy', _account_id: 2 as AccountId},
   id: 'the-reply' as UrlEncodedCommentId,
   message: 'keep it going',
   updated: '2021-11-02 10:11:12.000000000' as Timestamp,
@@ -70,7 +71,7 @@
 };
 
 const c3: DraftInfo = {
-  author: {name: 'Kermit'},
+  author: {name: 'Kermit', _account_id: 1 as AccountId},
   id: 'the-draft' as UrlEncodedCommentId,
   message: 'stop it',
   updated: '2021-11-03 10:11:12.000000000' as Timestamp,
@@ -79,7 +80,7 @@
 };
 
 const commentWithContext = {
-  author: {name: 'Kermit'},
+  author: {name: 'Kermit', _account_id: 1 as AccountId},
   id: 'the-draft' as UrlEncodedCommentId,
   message: 'just for context',
   updated: '2021-11-03 10:11:12.000000000' as Timestamp,
@@ -343,6 +344,7 @@
         .stub(testResolver(commentsModelToken), 'saveDraft')
         .returns(savePromise);
       stubAdd = sinon.stub(testResolver(commentsModelToken), 'addNewDraft');
+      sinon.stub(testResolver(commentsModelToken), 'discardDraft');
 
       element.thread = createThread(c1, {...c2, unresolved: true});
       await element.updateComplete;
@@ -707,7 +709,7 @@
             ...createComment(),
             id: '123' as any,
             message: 'Test comment',
-            author: {name: 'Test User'},
+            author: {name: 'Test User', _account_id: 12345 as AccountId},
             patch_set: 1 as RevisionPatchSetNum,
             line: 10,
             path: 'test.txt',
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 1f5a9eb..a4fc1e5 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -41,6 +41,7 @@
 import {SinonStub, SinonStubbedMember} from 'sinon';
 import {assert, fixture, html} from '@open-wc/testing';
 import {GrButton} from '../gr-button/gr-button';
+import {GrTooltipContent} from '../gr-tooltip-content/gr-tooltip-content';
 import {testResolver} from '../../../test/common-test-setup';
 import {
   CommentsModel,
@@ -384,7 +385,17 @@
             <gr-confirm-delete-comment-dialog id="confirmDeleteCommentDialog">
             </gr-confirm-delete-comment-dialog>
           </dialog>
-        `
+        `,
+        {ignoreAttributes: ['title']}
+      );
+
+      const tooltip = queryAndAssert<GrTooltipContent>(
+        element,
+        '.draftTooltip'
+      );
+      assert.equal(
+        tooltip.getAttribute('title') || (tooltip as any).originalTitle,
+        "This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'a' key."
       );
     });
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.ts
index 847d18a..60d8514 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.ts
@@ -49,7 +49,7 @@
   };
 
   setup(() => {
-    clock = useFakeTimers();
+    clock = useFakeTimers({shouldClearNativeTimers: true});
 
     stubRestApi('getAccount').resolves({
       name: 'Judy Hopps',
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts
index 3b07ddd..211d556 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts
@@ -24,7 +24,7 @@
   let bodyStub: sinon.SinonStub;
 
   setup(() => {
-    clock = sinon.useFakeTimers();
+    clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
 
     stubRestApi('getAccount').returns(
       Promise.resolve({name: 'Judy Hopps', registered_on: '' as Timestamp})
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
index 030b4a1..ec49dd7 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
@@ -48,7 +48,7 @@
   let authService: AuthService;
 
   setup(() => {
-    clock = sinon.useFakeTimers();
+    clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
     cache = new SiteBasedCache();
     fetchPromisesCache = new FetchPromisesCache();
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index c5fef11..1fb0664 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -367,6 +367,15 @@
   }
 
   protected override willUpdate(changedProperties: PropertyValues<this>): void {
+    if (changedProperties.has('groups')) {
+      if (this.groups?.length > 0) {
+        this.loading = false;
+      }
+    }
+    if (changedProperties.has('diff')) {
+      this.loading = true;
+    }
+
     if (
       changedProperties.has('diff') ||
       changedProperties.has('path') ||
@@ -472,11 +481,6 @@
       // diffChanged relies on diffElement having been rendered.
       this.diffChanged();
     }
-    if (changedProperties.has('groups')) {
-      if (this.groups?.length > 0) {
-        this.loading = false;
-      }
-    }
   }
 
   override render() {
@@ -688,7 +692,6 @@
   }
 
   private diffChanged() {
-    this.loading = true;
     if (this.diff && this.diffElement) {
       this.diffSelection.init(this.diff, this.diffElement);
       this.highlights.init(this.diffElement, this);
diff --git a/polygerrit-ui/app/models/checks/checks-model_test.ts b/polygerrit-ui/app/models/checks/checks-model_test.ts
index c43f7a15..94b3d0f 100644
--- a/polygerrit-ui/app/models/checks/checks-model_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-model_test.ts
@@ -99,7 +99,7 @@
   });
 
   test('register and fetch', async () => {
-    const clock = sinon.useFakeTimers();
+    const clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
     let change: ParsedChangeInfo | undefined = undefined;
     testResolver(changeModelToken).change$.subscribe(c => (change = c));
     const provider = createProvider();
@@ -131,7 +131,7 @@
   });
 
   test('fetch throttle', async () => {
-    const clock = sinon.useFakeTimers();
+    const clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
     let change: ParsedChangeInfo | undefined = undefined;
     testResolver(changeModelToken).change$.subscribe(c => (change = c));
     const provider = createProvider();
@@ -337,7 +337,7 @@
   });
 
   test('polls for changes', async () => {
-    const clock = sinon.useFakeTimers();
+    const clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
     let change: ParsedChangeInfo | undefined = undefined;
     testResolver(changeModelToken).change$.subscribe(c => (change = c));
     const provider = createProvider();
@@ -364,7 +364,7 @@
   });
 
   test('does not poll when config specifies 0 seconds', async () => {
-    const clock = sinon.useFakeTimers();
+    const clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
     let change: ParsedChangeInfo | undefined = undefined;
     testResolver(changeModelToken).change$.subscribe(c => (change = c));
     const provider = createProvider();
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index add53ac..5327041 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -25,7 +25,7 @@
     "resemblejs": "rsmbl/Resemble.js#66a55c5bfc3bda2303ad632ee8ce3c727b415917",
     "rxjs": "^6.6.7",
     "safevalues": "^1.2.0",
-    "web-vitals": "^3.5.2"
+    "web-vitals": "^5.1.0"
   },
   "dependencies // comments": {
     "@polymer/polymer": [
diff --git a/polygerrit-ui/app/scripts/polymer-resin-install.ts b/polygerrit-ui/app/scripts/polymer-resin-install.ts
index 527df12..584d83a 100644
--- a/polygerrit-ui/app/scripts/polymer-resin-install.ts
+++ b/polygerrit-ui/app/scripts/polymer-resin-install.ts
@@ -48,13 +48,18 @@
 export const _testOnly_defaultResinReportHandler =
   security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER;
 
+let resinInstalled = false;
 export function installPolymerResin(
   safeTypesBridge: SafeTypeBridge,
   reportHandler = security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER
 ) {
+  if (resinInstalled) {
+    return;
+  }
   window.security.polymer_resin.install({
     allowedIdentifierPrefixes: [''],
     reportHandler,
     safeTypesBridge,
   });
+  resinInstalled = true;
 }
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts
index 28be742..8840cd2 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts
@@ -56,7 +56,7 @@
     let fakeFetch: sinon.SinonStub;
     let clock: SinonFakeTimers;
     setup(() => {
-      clock = sinon.useFakeTimers();
+      clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
       fakeFetch = sinon.stub(window, 'fetch');
     });
 
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index 4ce0dd7..86757ae 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -15,7 +15,12 @@
   LifeCycle,
   Timing,
 } from '../../constants/reporting';
-import {Metric, onCLS, onINP, onLCP} from 'web-vitals';
+import {
+  MetricWithAttribution,
+  onCLS,
+  onINP,
+  onLCP,
+} from 'web-vitals/attribution';
 import {getEventPath, isElementTarget} from '../../utils/dom-util';
 import {Finalizable} from '../../types/types';
 
@@ -269,7 +274,7 @@
 }
 
 export function initWebVitals(reportingService: ReportingService) {
-  function reportWebVitalMetric(name: Timing, metric: Metric) {
+  function reportWebVitalMetric(name: Timing, metric: MetricWithAttribution) {
     let score = metric.value;
     // CLS good score is 0.1 and poor score is 0.25. Logging system
     // prefers integers, so we multiple by 100;
@@ -285,6 +290,7 @@
         navigationType: metric.navigationType,
         rating: metric.rating,
         entries: metric.entries,
+        attribution: metric.attribution,
       }
     );
   }
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
index d1d6638..3398967 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -3651,7 +3651,8 @@
   async getPatchContent(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
-    context?: number
+    context?: number,
+    errFn?: ErrorCallback
   ): Promise<string | undefined> {
     const url = await this._changeBaseURL(changeNum, patchNum);
     const params: {[key: string]: string | number} = {
@@ -3665,6 +3666,7 @@
       url: `${url}/patch`,
       params,
       anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/patch`,
+      errFn,
     });
     if (!response?.ok) return undefined;
     return await response.text();
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index 311dbd3..9b41a9f 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -842,7 +842,8 @@
   getPatchContent(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
-    context?: number
+    context?: number,
+    errFn?: ErrorCallback
   ): Promise<string | undefined>;
 
   getImagesForDiff(
diff --git a/polygerrit-ui/app/services/scheduler/retry-scheduler_test.ts b/polygerrit-ui/app/services/scheduler/retry-scheduler_test.ts
index 2609002..1162596 100644
--- a/polygerrit-ui/app/services/scheduler/retry-scheduler_test.ts
+++ b/polygerrit-ui/app/services/scheduler/retry-scheduler_test.ts
@@ -17,7 +17,7 @@
   let fakeScheduler: FakeScheduler<number>;
   let scheduler: Scheduler<number>;
   setup(() => {
-    clock = sinon.useFakeTimers();
+    clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
     fakeScheduler = new FakeScheduler<number>();
     scheduler = new RetryScheduler<number>(fakeScheduler, 3, 50, 1);
   });
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index 0322527..ace488c 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -44,12 +44,21 @@
 declare global {
   interface Window {
     sinon: typeof sinon;
+    litIssuedWarnings?: Set<string>;
   }
 }
 
 window.sinon = sinon;
+// Suppress 'Lit is in dev mode' warning. This is a development build, but we want
+// to keep the test output clean from unnecessary warnings.
+window.litIssuedWarnings = window.litIssuedWarnings || new Set();
+window.litIssuedWarnings.add('dev-mode');
 
 installPolymerResin(safeTypesBridge, (isViolation, fmt, ...args) => {
+  // Suppress 'initResin' log message from polymer-resin.
+  if (fmt === 'initResin') {
+    return;
+  }
   const log = _testOnly_defaultResinReportHandler;
   log(isViolation, fmt, ...args);
   if (isViolation) {
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index 400a7e6..422d00a 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -231,7 +231,7 @@
     return Promise.resolve({});
   },
   getChange(): Promise<ChangeInfo | undefined> {
-    throw new Error('getChange() not implemented by RestApiMock.');
+    return Promise.resolve(undefined);
   },
   getChangeActionURL(): Promise<string> {
     return Promise.resolve('');
diff --git a/polygerrit-ui/app/utils/async-util_test.ts b/polygerrit-ui/app/utils/async-util_test.ts
index 4383cbd..054a1b8 100644
--- a/polygerrit-ui/app/utils/async-util_test.ts
+++ b/polygerrit-ui/app/utils/async-util_test.ts
@@ -32,7 +32,11 @@
   suite('timeoutPromise', () => {
     let clock: SinonFakeTimers;
     setup(() => {
-      clock = sinon.useFakeTimers();
+      clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
+    });
+
+    teardown(() => {
+      clock.restore();
     });
     test('simple test', async () => {
       let resolved = false;
@@ -77,7 +81,11 @@
   suite('DelayedPromise', () => {
     let clock: SinonFakeTimers;
     setup(() => {
-      clock = sinon.useFakeTimers();
+      clock = sinon.useFakeTimers({shouldClearNativeTimers: true});
+    });
+
+    teardown(() => {
+      clock.restore();
     });
 
     test('It resolves after timeout', async () => {
@@ -137,13 +145,15 @@
         100
       );
       let hasCanceled = false;
-      promise.then((_value: number) => {
-        assert.fail();
-      });
-      promise.catch((reason?: any) => {
-        hasCanceled = true;
-        assert.strictEqual(reason, 'because');
-      });
+      promise.then(
+        (_value: number) => {
+          assert.fail();
+        },
+        (reason?: any) => {
+          hasCanceled = true;
+          assert.strictEqual(reason, 'because');
+        }
+      );
       await waitEventLoop();
       assert.isFalse(hasCanceled);
       promise.cancel('because');
diff --git a/polygerrit-ui/app/utils/autocomplete-cache_test.ts b/polygerrit-ui/app/utils/autocomplete-cache_test.ts
index 970436b..9aca3ec 100644
--- a/polygerrit-ui/app/utils/autocomplete-cache_test.ts
+++ b/polygerrit-ui/app/utils/autocomplete-cache_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2024 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import '../test/common-test-setup';
 import {AutocompleteCache} from './autocomplete-cache';
 import {assert} from '@open-wc/testing';
 
diff --git a/polygerrit-ui/app/utils/link-util_test.ts b/polygerrit-ui/app/utils/link-util_test.ts
index 1574b3e..7b09320 100644
--- a/polygerrit-ui/app/utils/link-util_test.ts
+++ b/polygerrit-ui/app/utils/link-util_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import '../test/common-test-setup';
 import {linkifyUrlsAndApplyRewrite} from './link-util';
 import {assert} from '@open-wc/testing';
 
diff --git a/polygerrit-ui/app/utils/message-util_test.ts b/polygerrit-ui/app/utils/message-util_test.ts
index bed9376..052c6a5 100644
--- a/polygerrit-ui/app/utils/message-util_test.ts
+++ b/polygerrit-ui/app/utils/message-util_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2023 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import '../test/common-test-setup';
 import {
   getCodeReviewVotesFromMessage,
   getRevertCreatedChangeIds,
diff --git a/polygerrit-ui/app/workers/service-worker-class_test.ts b/polygerrit-ui/app/workers/service-worker-class_test.ts
index 5368911..fd36168 100644
--- a/polygerrit-ui/app/workers/service-worker-class_test.ts
+++ b/polygerrit-ui/app/workers/service-worker-class_test.ts
@@ -24,7 +24,11 @@
       registration: {
         showNotification: () => {},
       },
-    } as {} as ServiceWorkerGlobalScope;
+      clients: {
+        matchAll: () => Promise.resolve([]),
+        openWindow: () => Promise.resolve(undefined),
+      },
+    } as unknown as ServiceWorkerGlobalScope;
     serviceWorker = new ServiceWorker(moctCtx);
     serviceWorker.allowBrowserNotificationsPreference = true;
   });
@@ -44,12 +48,16 @@
         },
       },
     };
-    sinon.useFakeTimers(t3);
+    const clock = sinon.useFakeTimers({
+      now: t3,
+      shouldClearNativeTimers: true,
+    });
     sinon
       .stub(serviceWorker, 'getLatestAttentionSetChanges')
       .returns(Promise.resolve([change]));
     const changes = await serviceWorker.getChangesToNotify(account);
     assert.equal(changes[0], change);
+    clock.restore();
   });
 
   test('check race condition', async () => {
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index c68262e..c26d0dd 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -420,10 +420,10 @@
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
   integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
 
-web-vitals@^3.5.2:
-  version "3.5.2"
-  resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-3.5.2.tgz#5bb58461bbc173c3f00c2ddff8bfe6e680999ca9"
-  integrity sha512-c0rhqNcHXRkY/ogGDJQxZ9Im9D19hDihbzSQJrsioex+KnFgmMzBiy57Z1EjkhX/+OjyBpclDCzz2ITtjokFmg==
+web-vitals@^5.1.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-5.1.0.tgz#2f117e92c8c4eeb107cb163cbb482ac20d685ebd"
+  integrity sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==
 
 wrappy@1:
   version "1.0.2"
diff --git a/polygerrit-ui/web-test-runner.config.mjs b/polygerrit-ui/web-test-runner.config.mjs
index f9b309d..0621a8f 100644
--- a/polygerrit-ui/web-test-runner.config.mjs
+++ b/polygerrit-ui/web-test-runner.config.mjs
@@ -4,14 +4,16 @@
 import { visualRegressionPlugin } from '@web/test-runner-visual-regression/plugin';
 import { playwrightLauncher } from '@web/test-runner-playwright';
 
-function testRunnerHtmlFactory() {
+const runUnderBazel = !!process.env['RUNFILES_DIR'];
+
+function testRunnerHtmlFactory(prefix) {
   return (testFramework) => `
     <!DOCTYPE html>
     <html>
       <head>
-        <link rel="stylesheet" href="polygerrit-ui/app/styles/main.css">
-        <link rel="stylesheet" href="polygerrit-ui/app/styles/fonts.css">
-        <link rel="stylesheet" href="polygerrit-ui/app/styles/material-icons.css">
+        <link rel="stylesheet" href="${prefix}app/styles/main.css">
+        <link rel="stylesheet" href="${prefix}app/styles/fonts.css">
+        <link rel="stylesheet" href="${prefix}app/styles/material-icons.css">
       </head>
       <body>
         <script type="module" src="${testFramework}"></script>
@@ -20,20 +22,18 @@
   `;
 }
 
-const runUnderBazel = !!process.env['RUNFILES_DIR'];
-
 function getModulesDir() {
   return runUnderBazel
     ? [
-        path.join(process.cwd(), 'external/plugins_npm/node_modules'),
-        path.join(process.cwd(), 'external/ui_npm/node_modules'),
-        path.join(process.cwd(), 'external/ui_dev_npm/node_modules'),
-      ]
+      path.join(process.cwd(), 'external/plugins_npm/node_modules'),
+      path.join(process.cwd(), 'external/ui_npm/node_modules'),
+      path.join(process.cwd(), 'external/ui_dev_npm/node_modules'),
+    ]
     : [
-        path.join(process.cwd(), 'plugins/node_modules'),
-        path.join(process.cwd(), 'app/node_modules'),
-        path.join(process.cwd(), 'node_modules'),
-      ];
+      path.join(process.cwd(), 'plugins/node_modules'),
+      path.join(process.cwd(), 'app/node_modules'),
+      path.join(process.cwd(), 'node_modules'),
+    ];
 }
 
 function getArgValue(flag) {
@@ -54,6 +54,11 @@
 const rootDir = getArgValue('--root-dir') ?? `${path.resolve(process.cwd())}/`;
 const tsConfig = getArgValue('--ts-config') ?? `${pathPrefix}app/tsconfig.json`;
 
+// When running screenshots, we serve from the root directory, so we need to
+// prepend polygerrit-ui/ to the path.
+// When running under Bazel, we also need strictly fully qualified paths.
+const stylePathPrefix = 'polygerrit-ui/';
+
 /** @type {import('@web/test-runner').TestRunnerConfig} */
 const config = {
   // Default is CPU cores / 2. Use default
@@ -77,22 +82,23 @@
   ],
 
   files: runScreenshots
-      ? [
-          // If --run-screenshots is set, ONLY run screenshot tests.
-          testFiles ?? `${pathPrefix}app/**/*_screenshot_test.{ts,js}`,
-          `!${pathPrefix}**/node_modules/**/*`,
-        ]
-      : [
-          // Otherwise, run all tests EXCEPT screenshot tests
-          testFiles ?? `${pathPrefix}app/**/*_test.{ts,js}`,
-          `!${pathPrefix}**/node_modules/**/*`,
-          `!${pathPrefix}app/**/*_screenshot_test.{ts,js}`,
-        ],
+    ? [
+      // If --run-screenshots is set, ONLY run screenshot tests.
+      testFiles ?? `${pathPrefix}app/**/*_screenshot_test.{ts,js}`,
+      `!${pathPrefix}**/node_modules/**/*`,
+    ]
+    : [
+      // Otherwise, run all tests EXCEPT screenshot tests
+      testFiles ?? `${pathPrefix}app/**/*_test.{ts,js}`,
+      `!${pathPrefix}**/node_modules/**/*`,
+      `!${pathPrefix}app/**/*_screenshot_test.{ts,js}`,
+    ],
 
   port: 9876,
 
   nodeResolve: {
     modulePaths: getModulesDir(),
+    dedupe: ['lit', 'lit-html', 'lit-element'],
   },
 
   testFramework: {
@@ -127,7 +133,7 @@
 
   // serve from gerrit root directory so that we can serve fonts from
   // /lib/fonts/ for screenshots tests, see middleware.
-  rootDir: runScreenshots ? '..' : rootDir,
+  rootDir: runUnderBazel ? rootDir : '..',
 
   reporters: [defaultReporter(), summaryReporter()],
 
@@ -143,7 +149,7 @@
     },
   ],
 
-  testRunnerHtml: testRunnerHtmlFactory(),
+  testRunnerHtml: testRunnerHtmlFactory(stylePathPrefix),
 };
 
 export default config;