mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 01:10:43 +08:00
This PR implements stricter deprecation handling that enforces deprecation-free tests for core and preinstalled plugins, while allowing custom (non-preinstalled) plugins and themes to have deprecations without causing test failures. ### Key Changes #### CI Workflow Improvements - Split plugin system tests into separate CI targets: `core-plugins`, `official-plugins`, and `chat` - Enhance `bin/turbo_rspec` to accept comma-separated exclude patterns via `--exclude-pattern` - Simplify workflow configuration with default `shell: bash` and consolidated environment variables #### Plugin Classification & Detection - Centralize official plugins list in `config/official_plugins.json` for unified backend and frontend access - Detect preinstalled plugins by checking for absence of `.git` directory - Add `isOfficial` and `isPreinstalled` metadata flags to plugin info - Add `data-preinstalled` and `data-official` attributes to all plugin and theme script tags for runtime identification #### Deprecation Source Tracking - Track deprecation sources (core, plugin, or theme) through template map and resolver to attribute deprecations correctly - Improve `source-identifier.js` to detect admin UI plugin files in both development and production environments - Add source information to deprecation messages for better debugging #### Test Infrastructure - Modify `raise-on-deprecation` test helper to skip errors for custom (non-preinstalled) plugins and themes - Add `EMBER_RAISE_ON_DEPRECATION` environment variable to control deprecation throwing behavior in Rails tests - Automatically set `EMBER_RAISE_ON_DEPRECATION` for core and preinstalled plugin/theme specs in `rails_helper.rb` - Improve deprecation summary output for system specs with test/spec origin tracking #### Deprecation Workflow Enhancements - Add `dont-throw` handler for selective deprecation bypassing in test fixtures without raising errors - Add `dont-count` handler for preventing deprecation counting in specific scenarios (e.g., test fixtures) #### Deprecation Fixes - Fix pending deprecations across core plugins (chat, data-explorer, discourse-subscriptions, gamification, house-ads, reactions, rss-polling, styleguide) - Update import paths and remove deprecated patterns - Migrate deprecated Handlebars templates to JavaScript API ### Testing Strategy With these changes: - **Core and preinstalled plugins** must pass all tests without any deprecations - **Custom plugins and themes** can have deprecations without failing tests - Test fixtures can use `dont-throw` and `dont-count` handlers when testing deprecation behavior itself - System specs automatically configure deprecation enforcement based on test file location --------- Co-authored-by: David Taylor <david@taylorhq.com> Co-authored-by: Jarek Radosz <jradosz@gmail.com>
387 lines
11 KiB
JavaScript
Vendored
387 lines
11 KiB
JavaScript
Vendored
"use strict";
|
|
|
|
const path = require("path");
|
|
const WatchedDir = require("broccoli-source").WatchedDir;
|
|
const Funnel = require("broccoli-funnel");
|
|
const mergeTrees = require("broccoli-merge-trees");
|
|
const fs = require("fs");
|
|
const concat = require("broccoli-concat");
|
|
const DiscoursePluginColocatedTemplateProcessor = require("./colocated-template-compiler");
|
|
const EmberApp = require("ember-cli/lib/broccoli/ember-app");
|
|
const transformActionSyntax = require("./transform-action-syntax");
|
|
|
|
function fixLegacyExtensions(tree) {
|
|
return new Funnel(tree, {
|
|
getDestinationPath: function (relativePath) {
|
|
if (relativePath.endsWith(".es6")) {
|
|
return relativePath.slice(0, -4);
|
|
}
|
|
|
|
return relativePath;
|
|
},
|
|
});
|
|
}
|
|
|
|
const COLOCATED_CONNECTOR_REGEX =
|
|
/^(?<prefix>.*)\/connectors\/(?<outlet>[^\/]+)\/(?<name>[^\/\.]+)\.(?<extension>.+)$/;
|
|
|
|
// Having connector templates and js in the same directory causes a clash
|
|
// when outputting es6 modules. This shim separates colocated connectors
|
|
// into separate js / template locations.
|
|
function unColocateConnectors(tree) {
|
|
return new Funnel(tree, {
|
|
getDestinationPath: function (relativePath) {
|
|
const match = relativePath.match(COLOCATED_CONNECTOR_REGEX);
|
|
if (
|
|
match &&
|
|
match.groups.extension === "hbs" &&
|
|
match.groups.prefix.split("/").pop() !== "templates"
|
|
) {
|
|
const { prefix, outlet, name } = match.groups;
|
|
return `${prefix}/templates/connectors/${outlet}/${name}.hbs`;
|
|
}
|
|
if (
|
|
match &&
|
|
match.groups.extension === "js" &&
|
|
match.groups.prefix.split("/").pop() === "templates"
|
|
) {
|
|
// Some plugins are colocating connector JS under `/templates`
|
|
const { prefix, outlet, name } = match.groups;
|
|
const newPrefix = prefix.slice(0, -"/templates".length);
|
|
return `${newPrefix}/connectors/${outlet}/${name}.js`;
|
|
}
|
|
return relativePath;
|
|
},
|
|
});
|
|
}
|
|
|
|
function namespaceModules(tree, pluginName) {
|
|
return new Funnel(tree, {
|
|
getDestinationPath: function (relativePath) {
|
|
return `discourse/plugins/${pluginName}/${relativePath}`;
|
|
},
|
|
});
|
|
}
|
|
|
|
function parsePluginName(pluginRbPath) {
|
|
const pluginRb = fs.readFileSync(pluginRbPath, "utf8");
|
|
// Match parsing logic in `lib/plugin/metadata.rb`
|
|
for (const line of pluginRb.split("\n")) {
|
|
if (line.startsWith("#")) {
|
|
const [attribute, value] = line.slice(1).split(":", 2);
|
|
if (attribute.trim() === "name") {
|
|
return value.trim();
|
|
}
|
|
}
|
|
}
|
|
throw new Error(
|
|
`Unable to parse plugin name from metadata in ${pluginRbPath}`
|
|
);
|
|
}
|
|
|
|
function getTestRequiredPlugins(aboutJsonPath) {
|
|
if (fs.existsSync(aboutJsonPath)) {
|
|
const aboutJson = JSON.parse(fs.readFileSync(aboutJsonPath, "utf8"));
|
|
const requiredPlugins = aboutJson.tests?.requiredPlugins || [];
|
|
return requiredPlugins.map((plugin) =>
|
|
plugin
|
|
.split("/")
|
|
.at(-1)
|
|
.replace(/\.git$/, "")
|
|
);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
module.exports = {
|
|
name: require("./package").name,
|
|
|
|
options: {
|
|
...require("../discourse/lib/common-babel-config")(),
|
|
|
|
"ember-this-fallback": {
|
|
enableLogging: false,
|
|
},
|
|
},
|
|
|
|
setupPreprocessorRegistry(type, registry) {
|
|
if (type === "self") {
|
|
const plugin = this._buildActionPlugin();
|
|
plugin.parallelBabel = {
|
|
requireFile: __filename,
|
|
buildUsing: "_buildActionPlugin",
|
|
params: {},
|
|
};
|
|
|
|
registry.add("htmlbars-ast-plugin", plugin);
|
|
}
|
|
},
|
|
|
|
_buildActionPlugin() {
|
|
return {
|
|
name: "transform-action-syntax",
|
|
plugin: transformActionSyntax,
|
|
baseDir: transformActionSyntax.baseDir,
|
|
cacheKey: transformActionSyntax.cacheKey,
|
|
};
|
|
},
|
|
|
|
pluginInfos() {
|
|
const root = path.resolve("../../plugins");
|
|
const pluginDirectories = fs
|
|
.readdirSync(root, { withFileTypes: true })
|
|
.filter(
|
|
(dirent) =>
|
|
(dirent.isDirectory() || dirent.isSymbolicLink()) &&
|
|
!dirent.name.startsWith(".") &&
|
|
fs.existsSync(path.resolve(root, dirent.name, "plugin.rb"))
|
|
);
|
|
|
|
// Load official plugins list
|
|
const officialPluginsPath = path.resolve(
|
|
"../../config/official_plugins.json"
|
|
);
|
|
const officialPlugins = JSON.parse(
|
|
fs.readFileSync(officialPluginsPath, "utf8")
|
|
);
|
|
|
|
return pluginDirectories.map((directory) => {
|
|
const directoryName = directory.name;
|
|
const pluginName = parsePluginName(
|
|
path.resolve(root, directoryName, "plugin.rb")
|
|
);
|
|
const jsDirectory = path.resolve(
|
|
root,
|
|
directoryName,
|
|
"assets/javascripts"
|
|
);
|
|
const adminJsDirectory = path.resolve(
|
|
root,
|
|
directoryName,
|
|
"admin/assets/javascripts"
|
|
);
|
|
const testDirectory = path.resolve(
|
|
root,
|
|
directoryName,
|
|
"test/javascripts"
|
|
);
|
|
const configDirectory = path.resolve(root, directoryName, "config");
|
|
const hasJs = fs.existsSync(jsDirectory);
|
|
const hasAdminJs = fs.existsSync(adminJsDirectory);
|
|
const hasTests = fs.existsSync(testDirectory);
|
|
const hasConfig = fs.existsSync(configDirectory);
|
|
const testRequiredPlugins = getTestRequiredPlugins(
|
|
path.resolve(root, directoryName, "about.json")
|
|
);
|
|
|
|
// Check if plugin is official
|
|
const isOfficial = officialPlugins.includes(pluginName);
|
|
|
|
// Check if plugin is preinstalled (bundled in discourse git repo)
|
|
const isPreinstalled = !fs.existsSync(
|
|
path.resolve(root, directoryName, ".git")
|
|
);
|
|
|
|
return {
|
|
pluginName,
|
|
directoryName,
|
|
jsDirectory,
|
|
adminJsDirectory,
|
|
testDirectory,
|
|
configDirectory,
|
|
hasJs,
|
|
hasAdminJs,
|
|
hasTests,
|
|
hasConfig,
|
|
testRequiredPlugins,
|
|
isOfficial,
|
|
isPreinstalled,
|
|
};
|
|
});
|
|
},
|
|
|
|
generatePluginsTree(withTests) {
|
|
if (!this.shouldLoadPlugins()) {
|
|
return mergeTrees([]);
|
|
}
|
|
const trees = [
|
|
this._generatePluginAppTree(),
|
|
this._generatePluginAdminTree(),
|
|
];
|
|
if (withTests) {
|
|
trees.push(this._generatePluginTestTree());
|
|
}
|
|
return mergeTrees(trees);
|
|
},
|
|
|
|
_generatePluginAppTree() {
|
|
const trees = this.pluginInfos()
|
|
.filter((p) => p.hasJs)
|
|
.map(({ pluginName, directoryName, jsDirectory }) =>
|
|
this._buildAppTree({
|
|
directory: jsDirectory,
|
|
pluginName,
|
|
outputFile: `assets/plugins/${directoryName}.js`,
|
|
})
|
|
);
|
|
return mergeTrees(trees);
|
|
},
|
|
|
|
_generatePluginAdminTree() {
|
|
const trees = this.pluginInfos()
|
|
.filter((p) => p.hasAdminJs)
|
|
.map(({ pluginName, directoryName, adminJsDirectory }) =>
|
|
this._buildAppTree({
|
|
directory: adminJsDirectory,
|
|
pluginName,
|
|
outputFile: `assets/plugins/${directoryName}_admin.js`,
|
|
})
|
|
);
|
|
return mergeTrees(trees);
|
|
},
|
|
|
|
_buildAppTree({ directory, pluginName, outputFile }) {
|
|
let tree = new WatchedDir(directory);
|
|
|
|
tree = fixLegacyExtensions(tree);
|
|
tree = unColocateConnectors(tree);
|
|
tree = namespaceModules(tree, pluginName);
|
|
|
|
const colocateBase = `discourse/plugins/${pluginName}`;
|
|
tree = new DiscoursePluginColocatedTemplateProcessor(
|
|
tree,
|
|
`${colocateBase}/discourse`
|
|
);
|
|
tree = new DiscoursePluginColocatedTemplateProcessor(
|
|
tree,
|
|
`${colocateBase}/admin`
|
|
);
|
|
tree = this.compileTemplates(tree);
|
|
|
|
tree = this.processedAddonJsFiles(tree);
|
|
|
|
return concat(mergeTrees([tree]), {
|
|
inputFiles: ["**/*.js"],
|
|
outputFile,
|
|
allowNone: true,
|
|
});
|
|
},
|
|
|
|
_generatePluginTestTree() {
|
|
const trees = this.pluginInfos()
|
|
.filter((p) => p.hasTests)
|
|
.map(({ pluginName, directoryName, testDirectory }) => {
|
|
let tree = new WatchedDir(testDirectory);
|
|
|
|
tree = fixLegacyExtensions(tree);
|
|
tree = namespaceModules(tree, pluginName);
|
|
tree = this.processedAddonJsFiles(tree);
|
|
|
|
return concat(mergeTrees([tree]), {
|
|
inputFiles: ["**/*.js"],
|
|
outputFile: `assets/plugins/test/${directoryName}_tests.js`,
|
|
allowNone: true,
|
|
});
|
|
});
|
|
return mergeTrees(trees);
|
|
},
|
|
|
|
shouldCompileTemplates() {
|
|
// The base Addon implementation checks for template
|
|
// files in the addon directories. We need to override that
|
|
// check so that the template compiler always runs.
|
|
return true;
|
|
},
|
|
|
|
// Matches logic from GlobalSetting.load_plugins? in the ruby app
|
|
shouldLoadPlugins() {
|
|
if (process.env.LOAD_PLUGINS === "1") {
|
|
return true;
|
|
} else if (process.env.LOAD_PLUGINS === "0") {
|
|
return false;
|
|
} else if (EmberApp.env() === "test") {
|
|
return false;
|
|
} else {
|
|
return true;
|
|
}
|
|
},
|
|
|
|
pluginScriptTags(config) {
|
|
const scripts = [];
|
|
|
|
const pluginInfos = this.pluginInfos();
|
|
|
|
for (const {
|
|
pluginName,
|
|
directoryName,
|
|
hasJs,
|
|
hasAdminJs,
|
|
isPreinstalled,
|
|
isOfficial,
|
|
} of pluginInfos) {
|
|
const pushScript = (suffix) => {
|
|
scripts.push({
|
|
src: `plugins/${directoryName}${suffix}.js`,
|
|
name: pluginName,
|
|
isPreinstalled,
|
|
isOfficial,
|
|
});
|
|
};
|
|
|
|
if (hasJs) {
|
|
pushScript("");
|
|
}
|
|
|
|
if (fs.existsSync(`../plugins/${directoryName}_extras.js.erb`)) {
|
|
pushScript("_extras");
|
|
}
|
|
|
|
if (hasAdminJs) {
|
|
pushScript("_admin");
|
|
}
|
|
}
|
|
|
|
const scriptTags = scripts
|
|
.map(
|
|
({ src, name, isPreinstalled, isOfficial }) =>
|
|
`<script src="${config.rootURL}assets/${src}" data-discourse-plugin="${name}" data-preinstalled="${isPreinstalled}" data-official="${isOfficial}"></script>`
|
|
)
|
|
.join("\n");
|
|
|
|
const requiredPluginInfos = {};
|
|
for (const { pluginName, testRequiredPlugins } of pluginInfos) {
|
|
requiredPluginInfos[pluginName] = testRequiredPlugins;
|
|
}
|
|
|
|
const requiredPluginInfoTag = `<script type="application/json" id="discourse-required-plugin-info">${JSON.stringify(requiredPluginInfos)}</script>`;
|
|
|
|
return `${scriptTags}\n${requiredPluginInfoTag}`;
|
|
},
|
|
|
|
pluginTestScriptTags(config) {
|
|
return this.pluginInfos()
|
|
.filter(({ hasTests }) => hasTests)
|
|
.map(
|
|
({ directoryName, pluginName, isPreinstalled, isOfficial }) =>
|
|
`<script src="${config.rootURL}assets/plugins/test/${directoryName}_tests.js" data-discourse-plugin="${pluginName}" data-preinstalled="${isPreinstalled}" data-official="${isOfficial}"></script>`
|
|
)
|
|
.join("\n");
|
|
},
|
|
|
|
contentFor(type, config) {
|
|
if (!this.shouldLoadPlugins()) {
|
|
return;
|
|
}
|
|
|
|
switch (type) {
|
|
case "test-plugin-js":
|
|
return this.pluginScriptTags(config);
|
|
|
|
case "test-plugin-tests-js":
|
|
return this.pluginTestScriptTags(config);
|
|
|
|
case "test-plugin-css":
|
|
return `<link rel="stylesheet" href="${config.rootURL}bootstrap/plugin-css-for-tests.css" data-discourse-plugin="_all" />`;
|
|
}
|
|
},
|
|
};
|