Repository navigation
test(e2e): Add nuxt-5-sourcemaps test #25268
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
f44da22
fix(nuxt): Delete public source maps before Nitro lists its public as…
s1gr1d 9795dd1
test(e2e): Add nuxt-5-sourcemaps test
s1gr1d 2228d4b
Merge branch 'develop' into sig/nuxt-5-source-maps-e2e
s1gr1d 20d1742
Merge branch 'develop' into sig/nuxt-5-source-maps-e2e
s1gr1d File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
23 changes: 23 additions & 0 deletions
23
dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/.gitignore
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| # Nuxt dev/build outputs | ||
| .output | ||
| .data | ||
| .nuxt | ||
| .nitro | ||
| .cache | ||
| dist | ||
|
|
||
| # Node dependencies | ||
| node_modules | ||
|
|
||
| # Mock Sentry server artifacts | ||
| .tmp_mock_uploads.json | ||
| .tmp_chunks | ||
| .tmp_build_stdout | ||
| .tmp_build_stderr | ||
|
|
||
| # Logs | ||
| logs | ||
| *.log | ||
|
|
||
| # Misc | ||
| .DS_Store |
12 changes: 12 additions & 0 deletions
12
dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/app/app.vue
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| <template> | ||
| <div> | ||
| <h1>Nuxt source map E2E</h1> | ||
| <NuxtPage /> | ||
| </div> | ||
| </template> | ||
|
|
||
| <script setup lang="ts"> | ||
| // SOURCEMAP_MARKER_CLIENT — a comment, so it is stripped from the emitted bundle but survives in | ||
| // `sourcesContent`. `assert-build.ts` uses it to tell "the real source was uploaded" apart from | ||
| // "the real source is still sitting in `.output/public`". | ||
| </script> |
9 changes: 9 additions & 0 deletions
9
dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/app/pages/index.vue
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| <template> | ||
| <button id="throw-error" @click="throwClientError">Throw client error</button> | ||
| </template> | ||
|
|
||
| <script setup lang="ts"> | ||
| function throwClientError(): void { | ||
| throw new Error('Client error from the Nuxt source map E2E app'); | ||
| } | ||
| </script> |
313 changes: 313 additions & 0 deletions
313
dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,313 @@ | ||
| import * as assert from 'assert/strict'; | ||
| import * as fs from 'fs'; | ||
| import * as path from 'path'; | ||
| import { | ||
| findDebugIdsWithConflictingSourcemaps, | ||
| findFilesWithForeignSourcemaps, | ||
| findFilesWithMultipleDebugIds, | ||
| findInjectedDebugIds, | ||
| findSourceMapFiles, | ||
| findSourceMappingUrlComments, | ||
| getArtifactBundles, | ||
| getAssembleRequests, | ||
| getChunkUploadPosts, | ||
| getDebugIdPairs, | ||
| getSourcemaps, | ||
| loadMockServerResults, | ||
| startNodeServer, | ||
| } from '@sentry-internal/test-utils'; | ||
|
|
||
| /** This variant omits `sourcemaps.filesToDeleteAfterUpload`, so Sentry must upload but not delete. */ | ||
| const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true'; | ||
|
|
||
| /** `nuxt generate` emits no `.output/server`. Keyed on the command so a missing one under `nuxt build` still fails. */ | ||
| const isStaticBuild = process.env.NUXT_COMMAND === 'generate'; | ||
|
|
||
| /** Nuxt 5 writes the client build here directly, so Sentry deletes the maps in Vite's only output directory. */ | ||
| const CLIENT_OUTPUT = path.join('.output', 'public'); | ||
| const SERVER_OUTPUT = path.join('.output', 'server'); | ||
|
|
||
| /** Both markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */ | ||
| const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT'; | ||
| const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER'; | ||
|
|
||
| const UUID_REGEX = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; | ||
|
|
||
| function filesContaining(dir: string, needle: string): string[] { | ||
| return fs | ||
| .readdirSync(dir, { recursive: true, withFileTypes: true }) | ||
| .filter(entry => entry.isFile()) | ||
| .map(entry => path.join(entry.parentPath, entry.name)) | ||
| .filter(file => fs.readFileSync(file, 'utf8').includes(needle)); | ||
| } | ||
|
|
||
| /** | ||
| * Rolldown emits no source map for a chunk without mappable source, such as Vue's export helper. | ||
| * Such a chunk still gets a debug ID, but there is no map to upload for it. | ||
| */ | ||
| function debugIdsOfChunksWithoutSourceMap(dir: string): Set<string> { | ||
| const debugIds = new Set<string>(); | ||
|
|
||
| for (const file of filesContaining(dir, 'sentry-dbid-')) { | ||
| if (file.endsWith('.map') || fs.existsSync(`${file}.map`)) { | ||
| continue; | ||
| } | ||
|
|
||
| for (const match of fs.readFileSync(file, 'utf8').matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { | ||
| debugIds.add((match[1] as string).toLowerCase()); | ||
| } | ||
| } | ||
|
|
||
| return debugIds; | ||
| } | ||
|
|
||
| // TODO: The uploader passes the emitted file paths to `glob` as patterns, so it skips a file whose path has glob | ||
| // characters, like Nitro 3's `_libs/_sentry/node+[...].mjs`. Remove this when the uploader matches paths literally. | ||
| function debugIdsOfFilesWithGlobCharacters(dir: string): Set<string> { | ||
| const debugIds = new Set<string>(); | ||
|
|
||
| for (const file of filesContaining(dir, 'sentry-dbid-')) { | ||
| if (!/[[\]{}()*?]/.test(path.relative(dir, file))) { | ||
| continue; | ||
| } | ||
|
|
||
| for (const match of fs.readFileSync(file, 'utf8').matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { | ||
| debugIds.add((match[1] as string).toLowerCase()); | ||
| } | ||
| } | ||
|
|
||
| return debugIds; | ||
| } | ||
|
|
||
| console.log( | ||
| `Variant: ${isStaticBuild ? 'nuxt generate' : 'nuxt build'}, ` + | ||
| `client source maps ${keepClientSourceMaps ? 'kept' : 'deleted'}\n`, | ||
| ); | ||
|
|
||
| const requests = loadMockServerResults(); | ||
|
|
||
| console.log(`Captured ${requests.length} requests to mock Sentry server:\n`); | ||
| for (const request of requests) { | ||
| console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`); | ||
| } | ||
| console.log(''); | ||
|
|
||
| // --- The upload reached Sentry --- | ||
|
|
||
| assert.ok( | ||
| requests.some(r => r.authorization.includes('fake-auth-token')), | ||
| 'Expected requests with the configured auth token', | ||
| ); | ||
|
|
||
| // Sentry rejects a release that names no project with a 400, and the mock server accepts anything, | ||
| // so assert on the body rather than on the call alone. | ||
| const releaseCreates = requests.filter(r => r.url?.includes('/releases') && r.method === 'POST'); | ||
| assert.ok(releaseCreates.length > 0, 'Expected a POST to create the release'); | ||
| for (const request of releaseCreates) { | ||
| const body = request.jsonBody as { version?: string; projects?: string[] } | undefined; | ||
| assert.equal( | ||
| body?.version, | ||
| 'test-release', | ||
| `Expected the release create body to carry the version, got ${JSON.stringify(body)}`, | ||
| ); | ||
| assert.deepEqual( | ||
| body?.projects, | ||
| ['test-project'], | ||
| `Expected the release create body to name the project, got ${JSON.stringify(body)}`, | ||
| ); | ||
| } | ||
|
|
||
| assert.ok( | ||
| requests.some(r => r.url?.includes('/releases/') && r.method === 'PUT'), | ||
| 'Expected a PUT to finalize the release', | ||
| ); | ||
|
|
||
| assert.ok( | ||
| requests.some(r => r.url?.includes('/chunk-upload/') && r.method === 'GET'), | ||
| 'Expected a GET for the chunk-upload options', | ||
| ); | ||
|
|
||
| const chunkPosts = getChunkUploadPosts(requests); | ||
| assert.ok( | ||
| chunkPosts.some(r => r.bodySize > 0), | ||
| 'Expected at least one chunk upload POST with a non-empty body', | ||
| ); | ||
|
|
||
| const assembleRequests = getAssembleRequests(requests); | ||
| assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request'); | ||
| for (const request of assembleRequests) { | ||
| assert.ok(request.assembleBody?.projects?.includes('test-project'), 'Expected assemble request for test-project'); | ||
| assert.equal(request.assembleBody?.version, 'test-release', 'Expected assemble request to reference the release'); | ||
| assert.ok((request.assembleBody?.chunks?.length ?? 0) > 0, 'Expected assemble request to have chunk checksums'); | ||
| for (const chunk of request.assembleBody?.chunks ?? []) { | ||
| assert.match(chunk, /^[\da-f]{40}$/i, `Expected a SHA-1 chunk checksum, got: ${chunk}`); | ||
| } | ||
| } | ||
|
|
||
| const bundles = getArtifactBundles(requests); | ||
| assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); | ||
| console.log(`Found ${bundles.length} artifact bundle(s)\n`); | ||
|
|
||
| // --- Both bundlers uploaded --- | ||
|
|
||
| const sourcemaps = getSourcemaps(bundles); | ||
| assert.ok( | ||
| sourcemaps.some(map => map.sourcemap.mappings?.length), | ||
| 'Expected at least one uploaded sourcemap with non-empty mappings', | ||
| ); | ||
|
|
||
| const containsMarker = (marker: string): boolean => | ||
| sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); | ||
|
|
||
| // Vite builds the client and Nitro builds the server. Counting bundles would still pass with either | ||
| // plugin dropped, so each side is pinned to a marker only that side's source supplies. | ||
| assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source (Vite plugin)'); | ||
| // TODO: Nitro 3 builds the server with Rolldown, and the uploaded server maps carry no `sourcesContent`: the | ||
| // module's `sourcemapExcludeSources: false` does not reach that build. Assert the server marker when that is fixed: | ||
| // assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source (Rollup plugin)'); | ||
|
|
||
| // `rewriteSources` normalizes `../../../foo` to `./foo` so paths stay resolvable in Sentry. | ||
| const unnormalizedSources = [...new Set(sourcemaps.flatMap(map => map.sourcemap.sources ?? []))].filter( | ||
| source => source.startsWith('../') || path.isAbsolute(source), | ||
| ); | ||
| assert.deepEqual(unnormalizedSources, [], `Expected every uploaded source to be normalized to './…'`); | ||
|
|
||
| // --- Debug IDs tie the shipped bundle to the uploaded map --- | ||
|
|
||
| const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase())); | ||
| assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs'); | ||
|
|
||
| const malformedDebugIds = [...uploadedDebugIds].filter(debugId => !UUID_REGEX.test(debugId)); | ||
| assert.deepEqual(malformedDebugIds, [], 'Expected every uploaded debug ID to be a UUID'); | ||
|
|
||
| // An uploaded map is only reachable at runtime if the shipped bundle claims the same ID. Inspecting | ||
| // the upload alone cannot show this. | ||
| // Nuxt 5 writes the client build to `CLIENT_OUTPUT` directly. After Sentry deletes the client maps there, a chunk | ||
| // that never had a map looks the same as one whose map was deleted. So the client upload is checked only when the | ||
| // maps are kept. The upload is the same in all variants. | ||
| const outputDirsWithSourceMaps = [ | ||
| ...(keepClientSourceMaps ? [CLIENT_OUTPUT] : []), | ||
| ...(isStaticBuild ? [] : [SERVER_OUTPUT]), | ||
| ]; | ||
| const debugIdsWithoutSourceMap = new Set( | ||
| outputDirsWithSourceMaps.flatMap(dir => [...debugIdsOfChunksWithoutSourceMap(dir)]), | ||
| ); | ||
| const debugIdsSkippedByUploader = isStaticBuild ? new Set<string>() : debugIdsOfFilesWithGlobCharacters(SERVER_OUTPUT); | ||
|
|
||
| for (const outputDir of isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT]) { | ||
| const injectedDebugIds = findInjectedDebugIds({ outputDir }); | ||
| assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); | ||
| // The exemption for chunks without a source map must not let a build without any uploaded map pass. | ||
| assert.ok( | ||
| injectedDebugIds.some(debugId => uploadedDebugIds.has(debugId)), | ||
| `Expected at least one debug ID in ${outputDir} to have an uploaded sourcemap`, | ||
| ); | ||
|
|
||
| if (outputDirsWithSourceMaps.includes(outputDir)) { | ||
| const unuploaded = injectedDebugIds.filter( | ||
| debugId => | ||
| !uploadedDebugIds.has(debugId) && | ||
| !debugIdsWithoutSourceMap.has(debugId) && | ||
| !debugIdsSkippedByUploader.has(debugId), | ||
| ); | ||
| assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`); | ||
| } | ||
|
|
||
| console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s)`); | ||
|
|
||
| // Nitro bundles the SSR build again, so its debug IDs can end up next to Nitro's own. | ||
| // Reference issue: https://github.com/getsentry/sentry-javascript/issues/18519 | ||
| const filesWithMultipleDebugIds = findFilesWithMultipleDebugIds({ outputDir }); | ||
| assert.deepEqual(filesWithMultipleDebugIds, [], `Expected at most one debug ID per file in ${outputDir}`); | ||
|
|
||
| // A server file can borrow the debug ID of its SSR build chunk. Then the upload check above passes on | ||
| // Vite's map even if Nitro uploaded nothing. | ||
| const filesWithForeignSourcemaps = findFilesWithForeignSourcemaps({ outputDir }, sourcemaps); | ||
| assert.deepEqual( | ||
| filesWithForeignSourcemaps, | ||
| [], | ||
| `Expected the source map uploaded for each file in ${outputDir} to be generated for that file`, | ||
| ); | ||
| } | ||
|
|
||
| // If both builds upload a map under a borrowed debug ID, Sentry can resolve with the wrong one. | ||
| const conflictingDebugIds = findDebugIdsWithConflictingSourcemaps(sourcemaps); | ||
| assert.deepEqual(conflictingDebugIds, [], 'Expected every uploaded debug ID to resolve to exactly one source map'); | ||
|
|
||
| // TODO: Nitro 3 bundles the SSR build's output again, but its maps stop at that output | ||
| // (`.nuxt/vite/services/ssr/...`) and do not chain to the SSR build's own maps. So server frames do not reach | ||
| // `.vue` files. Assert this when the server maps reach the original sources: | ||
| // const serverDebugIds = new Set(findInjectedDebugIds({ outputDir: SERVER_OUTPUT })); | ||
| // const serverSources = sourcemaps | ||
| // .filter(map => map.debugId && serverDebugIds.has(map.debugId)) | ||
| // .flatMap(map => getSourcemapSources(map.sourcemap)); | ||
| // assert.ok(serverSources.some(source => source.endsWith('app/pages/index.vue'))); | ||
| console.log(''); | ||
|
|
||
| // --- What the build leaves behind in the client output --- | ||
|
|
||
| const clientSourceMaps = findSourceMapFiles({ outputDir: CLIENT_OUTPUT }); | ||
|
|
||
| if (keepClientSourceMaps) { | ||
| assert.ok(clientSourceMaps.length > 0, `Expected Sentry to leave the user-enabled maps in ${CLIENT_OUTPUT}`); | ||
| console.log(` ${clientSourceMaps.length} source map(s) kept in ${CLIENT_OUTPUT}, as configured\n`); | ||
| } else { | ||
| // This directory is served to the internet, so a surviving `.map` hands out the original source. | ||
| assert.deepEqual(clientSourceMaps, [], `Expected no source maps in ${CLIENT_OUTPUT} after upload`); | ||
|
|
||
| // The maps are gone, so a surviving reference only 404s in devtools and leaks where they were. | ||
| const danglingReferences = findSourceMappingUrlComments({ outputDir: CLIENT_OUTPUT }); | ||
| assert.deepEqual(danglingReferences, [], `Expected no sourceMappingURL comments in ${CLIENT_OUTPUT}`); | ||
|
|
||
| // Catches source maps inlined as `data:` URIs, which the reference check above skips by design. | ||
| const leakedSource = filesContaining(CLIENT_OUTPUT, CLIENT_MARKER); | ||
| assert.deepEqual(leakedSource, [], `Expected no original client source under ${CLIENT_OUTPUT}`); | ||
|
|
||
| console.log(` ${CLIENT_OUTPUT} is free of source maps, sourceMappingURL comments and original source\n`); | ||
| } | ||
|
|
||
| // --- What the built server answers for the client source maps --- | ||
|
|
||
| // `nuxt generate` emits no server, and a static host serves only the files that exist. | ||
| if (!isStaticBuild) { | ||
| const clientChunkUrls = fs | ||
| .readdirSync(CLIENT_OUTPUT, { recursive: true, withFileTypes: true }) | ||
| .filter(entry => entry.isFile() && entry.name.endsWith('.js')) | ||
| .map( | ||
| entry => `/${path.relative(CLIENT_OUTPUT, path.join(entry.parentPath, entry.name)).split(path.sep).join('/')}`, | ||
| ); | ||
| assert.ok(clientChunkUrls.length > 0, `Expected client chunks in ${CLIENT_OUTPUT}`); | ||
|
|
||
| const server = await startNodeServer({ entry: path.join(SERVER_OUTPUT, 'index.mjs'), port: 3030 }); | ||
|
|
||
| try { | ||
| // Without this, a server that serves no public files at all would pass the 404 checks. | ||
| const chunkStatus = (await fetch(`${server.baseUrl}${clientChunkUrls[0]}`)).status; | ||
| assert.equal(chunkStatus, 200, `Expected the server to serve the client chunk ${clientChunkUrls[0]}`); | ||
|
|
||
| // Nitro serves public files from a list it writes into the server bundle. A map deleted after that | ||
| // stays on the list, and Nitro answers its request with a 500 instead of a 404. | ||
| const responses = await Promise.all( | ||
| clientChunkUrls.map(async chunkUrl => { | ||
| const mapUrl = `${chunkUrl}.map`; | ||
| const expectedStatus = fs.existsSync(path.join(CLIENT_OUTPUT, mapUrl)) ? 200 : 404; | ||
| const { status } = await fetch(`${server.baseUrl}${mapUrl}`); | ||
| return { mapUrl, status, expectedStatus }; | ||
| }), | ||
| ); | ||
| const unexpectedStatuses = responses | ||
| .filter(({ status, expectedStatus }) => status !== expectedStatus) | ||
| .map(({ mapUrl, status, expectedStatus }) => `${mapUrl} → ${status}, expected ${expectedStatus}`); | ||
| assert.deepEqual( | ||
| unexpectedStatuses, | ||
| [], | ||
| 'Expected the server to answer each client source map URL with 200 if the map is in the output, else 404', | ||
| ); | ||
|
|
||
| console.log(` The server answers all ${clientChunkUrls.length} client source map URL(s) as expected\n`); | ||
| } finally { | ||
| await server.close(); | ||
| } | ||
| } | ||
|
|
||
| console.log('All sourcemap assertions passed!'); | ||
33 changes: 33 additions & 0 deletions
33
dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/nuxt.config.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| // Nuxt defaults `sourcemap.client` to `false`, which the SDK respects as a deliberate opt-out, so | ||
| // an app that never mentions `sourcemap`, uploads nothing client-side. `'hidden'` is what the SDK's | ||
| // own warning tells users to set, which makes it the setup worth regression-testing. | ||
| const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true'; | ||
|
|
||
| export default defineNuxtConfig({ | ||
| compatibilityDate: '2025-06-06', | ||
| imports: { autoImport: false }, | ||
|
|
||
| sourcemap: { client: 'hidden' }, | ||
|
|
||
| modules: ['@sentry/nuxt/module'], | ||
|
|
||
| runtimeConfig: { | ||
| public: { | ||
| sentry: { | ||
| dsn: 'https://public@dsn.ingest.sentry.io/1337', | ||
| }, | ||
| }, | ||
| }, | ||
|
|
||
| sentry: { | ||
| sentryUrl: 'http://localhost:3032', | ||
| authToken: 'fake-auth-token', | ||
| org: 'test-org', | ||
| project: 'test-project', | ||
| release: { name: 'test-release' }, | ||
| // Dropping `filesToDeleteAfterUpload` is the whole point of the "kept" variant: Sentry should | ||
| // upload the maps and leave the emitted files alone. | ||
| sourcemaps: keepClientSourceMaps ? {} : { filesToDeleteAfterUpload: ['.output/public/**/*.map'] }, | ||
| debug: true, | ||
| }, | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
are there any issues tracking these?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not yet, I created them now as sub-issues of this one: #25319
I'll link them in the TODOs.