diff --git a/app/assets/javascripts/discourse-common/addon/lib/deprecated.js b/app/assets/javascripts/discourse-common/addon/lib/deprecated.js index 1c36871d6af..a02bddfebaf 100644 --- a/app/assets/javascripts/discourse-common/addon/lib/deprecated.js +++ b/app/assets/javascripts/discourse-common/addon/lib/deprecated.js @@ -1,14 +1,40 @@ -export default function deprecated(msg, opts = {}) { - msg = ["Deprecation notice:", msg]; - if (opts.since) { - msg.push(`(deprecated since Discourse ${opts.since})`); +const handlers = []; +const disabledDeprecations = new Set(); + +/** + * Display a deprecation warning with the provided message. The warning will be prefixed with the theme/plugin name + * if it can be automatically determined based on the current stack. + * @param {String} msg The deprecation message + * @param {Object} options + * @param {String} [options.id] A unique identifier for this deprecation. This should be namespaced by dots (e.g. discourse.my_deprecation) + * @param {String} [options.since] The Discourse version this deprecation was introduced in + * @param {String} [options.dropFrom] The Discourse version this deprecation will be dropped in. Typically one major version after `since` + * @param {String} [options.url] A URL which provides more detail about the deprecation + * @param {boolean} [options.raiseError] Raise an error when this deprecation is triggered. Defaults to `false` + */ +export default function deprecated(msg, options) { + const { id, since, dropFrom, url, raiseError } = options; + + if (id && disabledDeprecations.has(id)) { + return; } - if (opts.dropFrom) { - msg.push(`(removal in Discourse ${opts.dropFrom})`); + + msg = ["Deprecation notice:", msg]; + if (since) { + msg.push(`[deprecated since Discourse ${since}]`); + } + if (dropFrom) { + msg.push(`[removal in Discourse ${dropFrom}]`); + } + if (id) { + msg.push(`[deprecation id: ${id}]`); + } + if (url) { + msg.push(`[info: ${url}]`); } msg = msg.join(" "); - if (opts.raiseError) { + if (raiseError) { throw msg; } @@ -20,4 +46,29 @@ export default function deprecated(msg, opts = {}) { } console.warn(consolePrefix, msg); //eslint-disable-line no-console + + handlers.forEach((h) => h(msg, options)); +} + +/** + * Register a function which will be called whenever a deprecation is triggered + * @param {function} callback The callback function. Arguments will match those of `deprecated()`. + */ +export function registerDeprecationHandler(callback) { + handlers.push(callback); +} + +/** + * Silence one or more deprecations while running `callback` + * @async + * @param {(string|string[])} deprecationIds A single id, or an array of ids, of deprecations to silence + * @param {function} callback The function to call while deprecations are silenced. Can be asynchronous. + */ +export async function withSilencedDeprecations(deprecationIds, callback) { + try { + Array(deprecationIds).forEach((id) => disabledDeprecations.add(id)); + return await callback(); + } finally { + Array(deprecationIds).forEach((id) => disabledDeprecations.delete(id)); + } } diff --git a/app/assets/javascripts/discourse/app/components/user-selector.js b/app/assets/javascripts/discourse/app/components/user-selector.js index 4f84623fe52..17c85727db2 100644 --- a/app/assets/javascripts/discourse/app/components/user-selector.js +++ b/app/assets/javascripts/discourse/app/components/user-selector.js @@ -16,8 +16,8 @@ export default TextField.extend({ @on("init") deprecateComponent() { deprecated( - "`{{user-selector}}` is deprecated. Please use `{{email-group-user-chooser}}` instead.", - { since: "2.7", dropFrom: "2.8" } + "The `` component is deprecated. Please use `` instead.", + { since: "2.7", dropFrom: "2.8", id: "discourse.user-selector-component" } ); }, diff --git a/app/assets/javascripts/discourse/testem.js b/app/assets/javascripts/discourse/testem.js index 1bcc31d2cda..922a6da9059 100644 --- a/app/assets/javascripts/discourse/testem.js +++ b/app/assets/javascripts/discourse/testem.js @@ -1,15 +1,21 @@ const TapReporter = require("testem/lib/reporters/tap_reporter"); const { shouldLoadPluginTestJs } = require("discourse/lib/plugin-js"); +const fs = require("fs"); class Reporter { failReports = []; + deprecationCounts = new Map(); constructor() { this._tapReporter = new TapReporter(...arguments); } reportMetadata(tag, metadata) { - if (tag === "summary-line") { + if (tag === "increment-deprecation") { + const id = metadata.id; + const currentCount = this.deprecationCounts.get(id) || 0; + this.deprecationCounts.set(id, currentCount + 1); + } else if (tag === "summary-line") { process.stdout.write(`\n${metadata.message}\n`); } else { this._tapReporter.reportMetadata(...arguments); @@ -23,9 +29,46 @@ class Reporter { this._tapReporter.report(prefix, data); } + generateDeprecationTable() { + const maxIdLength = Math.max( + ...Array.from(this.deprecationCounts.keys()).map((k) => k.length) + ); + + let msg = `| ${"id".padEnd(maxIdLength)} | count |\n`; + msg += `| ${"".padEnd(maxIdLength, "-")} | ----- |\n`; + + for (const [id, count] of this.deprecationCounts.entries()) { + const countString = count.toString(); + msg += `| ${id.padEnd(maxIdLength)} | ${countString.padStart(5)} |\n`; + } + + return msg; + } + + reportDeprecations() { + let deprecationMessage = "[Deprecation Counter] "; + if (this.deprecationCounts.size > 0) { + const table = this.generateDeprecationTable(); + deprecationMessage += `Test run completed with deprecations:\n\n${table}`; + + if (process.env.GITHUB_ACTIONS && process.env.GITHUB_STEP_SUMMARY) { + let jobSummary = `### ⚠️ JS Deprecations\n\nTest run completed with deprecations:\n\n`; + jobSummary += table; + jobSummary += `\n\n`; + + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, jobSummary); + } + } else { + deprecationMessage += "No deprecations logged"; + } + process.stdout.write(`\n${deprecationMessage}\n\n`); + } + finish() { this._tapReporter.finish(); + this.reportDeprecations(); + if (this.failReports.length > 0) { process.stdout.write("\nFailures:\n\n"); diff --git a/app/assets/javascripts/discourse/tests/helpers/deprecation-counter.js b/app/assets/javascripts/discourse/tests/helpers/deprecation-counter.js new file mode 100644 index 00000000000..166c7e475a9 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/helpers/deprecation-counter.js @@ -0,0 +1,96 @@ +import { registerDeprecationHandler } from "@ember/debug"; +import { bind } from "discourse-common/utils/decorators"; +import { registerDeprecationHandler as registerDiscourseDeprecationHandler } from "discourse-common/lib/deprecated"; + +export default class DeprecationCounter { + counts = new Map(); + #configById = new Map(); + + constructor(config) { + for (const c of config) { + this.#configById.set(c.matchId, c.handler); + } + } + + start() { + registerDeprecationHandler(this.handleEmberDeprecation); + registerDiscourseDeprecationHandler(this.handleDiscourseDeprecation); + } + + @bind + handleEmberDeprecation(message, options, next) { + const { id } = options; + const matchingConfig = this.#configById.get(id); + + if (matchingConfig !== "silence") { + this.incrementDeprecation(id); + } + + next(message, options); + } + + @bind + handleDiscourseDeprecation(message, options) { + let { id } = options; + id ||= "discourse.(unknown)"; + + this.incrementDeprecation(id); + } + + incrementDeprecation(id) { + const existingCount = this.counts.get(id) || 0; + this.counts.set(id, existingCount + 1); + if (window.Testem) { + reportToTestem(id); + } + } + + get hasDeprecations() { + return this.counts.size > 0; + } + + generateTable() { + const maxIdLength = Math.max( + ...Array.from(this.counts.keys()).map((k) => k.length) + ); + + let msg = `| ${"id".padEnd(maxIdLength)} | count |\n`; + msg += `| ${"".padEnd(maxIdLength, "-")} | ----- |\n`; + + for (const [id, count] of this.counts.entries()) { + const countString = count.toString(); + msg += `| ${id.padEnd(maxIdLength)} | ${countString.padStart(5)} |\n`; + } + + return msg; + } +} + +function reportToTestem(id) { + window.Testem.useCustomAdapter(function (socket) { + socket.emit("test-metadata", "increment-deprecation", { + id, + }); + }); +} + +export function setupDeprecationCounter(qunit) { + const config = window.deprecationWorkflow?.config?.workflow || {}; + const deprecationCounter = new DeprecationCounter(config); + + qunit.begin(() => deprecationCounter.start()); + + qunit.done(() => { + if (window.Testem) { + return; + } else if (deprecationCounter.hasDeprecations) { + // eslint-disable-next-line no-console + console.warn( + `[Discourse Deprecation Counter] Test run completed with deprecations:\n\n${deprecationCounter.generateTable()}` + ); + } else { + // eslint-disable-next-line no-console + console.log("[Discourse Deprecation Counter] No deprecations found"); + } + }); +} diff --git a/app/assets/javascripts/discourse/tests/integration/components/user-selector-test.js b/app/assets/javascripts/discourse/tests/integration/components/user-selector-test.js index 196eb88ead1..7a474c36869 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/user-selector-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/user-selector-test.js @@ -3,6 +3,7 @@ import { setupRenderingTest } from "discourse/tests/helpers/component-test"; import { render } from "@ember/test-helpers"; import { query } from "discourse/tests/helpers/qunit-helpers"; import { hbs } from "ember-cli-htmlbars"; +import { withSilencedDeprecations } from "discourse-common/lib/deprecated"; function paste(element, text) { let e = new Event("paste"); @@ -16,8 +17,13 @@ module("Integration | Component | user-selector", function (hooks) { test("pasting a list of usernames", async function (assert) { this.set("usernames", "evil,trout"); - await render( - hbs`` + await withSilencedDeprecations( + "discourse.user-selector-component", + async () => { + await render( + hbs`` + ); + } ); let element = query(".test-selector"); @@ -45,8 +51,13 @@ module("Integration | Component | user-selector", function (hooks) { this.set("usernames", "mark"); this.set("excludedUsernames", ["jeff", "sam", "robin"]); - await render( - hbs`` + await withSilencedDeprecations( + "discourse.user-selector-component", + async () => { + await render( + hbs`` + ); + } ); let element = query(".test-selector"); diff --git a/app/assets/javascripts/discourse/tests/setup-tests.js b/app/assets/javascripts/discourse/tests/setup-tests.js index d95039c0604..6aba31ae171 100644 --- a/app/assets/javascripts/discourse/tests/setup-tests.js +++ b/app/assets/javascripts/discourse/tests/setup-tests.js @@ -40,6 +40,7 @@ import { clearState as clearPresenceState } from "discourse/tests/helpers/presen import { addModuleExcludeMatcher } from "ember-cli-test-loader/test-support/index"; import SiteSettingService from "discourse/services/site-settings"; import jQuery from "jquery"; +import { setupDeprecationCounter } from "discourse/tests/helpers/deprecation-counter"; const Plugin = $.fn.modal; const Modal = Plugin.Constructor; @@ -199,6 +200,8 @@ function writeSummaryLine(message) { export default function setupTests(config) { disableCloaking(); + setupDeprecationCounter(QUnit); + QUnit.config.hidepassed = true; sinon.config = {