diff --git a/client-src/elements/chromedash-feature-page.ts b/client-src/elements/chromedash-feature-page.ts index cb2a981523c1..fd04efb603cf 100644 --- a/client-src/elements/chromedash-feature-page.ts +++ b/client-src/elements/chromedash-feature-page.ts @@ -14,7 +14,12 @@ import {DETAILS_STYLES} from './chromedash-feature-detail'; import './chromedash-feature-highlights.js'; import {GateDict} from './chromedash-gate-chip.js'; import {Process, ProgressItem} from './chromedash-gate-column.js'; -import {showToastMessage, isVerifiedWithinGracePeriod} from './utils.js'; +import { + showToastMessage, + getFeatureOutdatedBanner, + findClosestShippingDate, + closestShippingDateInfo, +} from './utils.js'; import { STAGE_TYPES_SHIPPING, STAGE_TYPES_ORIGIN_TRIAL, @@ -116,14 +121,14 @@ export class ChromedashFeaturePage extends LitElement { @state() loading = true; @state() - isUpcoming = false; - @state() - hasShipped = false; - @state() currentDate: number = Date.now(); @state() - // The closest milestone shipping date as an ISO string. - closestShippingDate: string = ''; + shippingInfo: closestShippingDateInfo = { + // The closest milestone shipping date as an ISO string. + closestShippingDate: '', + hasShipped: false, + isUpcoming: false, + }; connectedCallback() { super.connectedCallback(); @@ -134,155 +139,6 @@ export class ChromedashFeaturePage extends LitElement { return this.feature && Object.keys(this.feature).length !== 0; } - async fetchClosestShippingDate(milestone: number): Promise { - if (milestone === 0) { - return ''; - } - try { - const newMilestonesInfo = await window.csClient.getSpecifiedChannels( - milestone, - milestone - ); - return newMilestonesInfo[milestone]?.final_beta; - } catch { - showToastMessage( - 'Some errors occurred. Please refresh the page or try again later.' - ); - return ''; - } - } - - /** - * Determine if this feature is upcoming - scheduled to ship - * within two milestones, then find the closest shipping date - * for that upcoming milestone or an already shipped milestone.*/ - async findClosestShippingDate(channels, stages: Array) { - const latestStableVersion = channels['stable']?.version; - if (!latestStableVersion || !stages) { - return; - } - - const shippingTypeMilestones = new Set(); - const otTypeMilestones = new Set(); - for (const stage of stages) { - if (STAGE_TYPES_SHIPPING.has(stage.stage_type)) { - shippingTypeMilestones.add(stage.desktop_first); - shippingTypeMilestones.add(stage.android_first); - shippingTypeMilestones.add(stage.ios_first); - shippingTypeMilestones.add(stage.webview_first); - } - } - for (const stage of stages) { - if (STAGE_TYPES_ORIGIN_TRIAL.has(stage.stage_type)) { - otTypeMilestones.add(stage.desktop_first); - otTypeMilestones.add(stage.android_first); - otTypeMilestones.add(stage.ios_first); - otTypeMilestones.add(stage.webview_first); - } - } - - const upcomingMilestonesTarget = new Set([ - ...shippingTypeMilestones, - ...otTypeMilestones, - ]); - // Check if this feature is shipped within two milestones. - let foundMilestone = 0; - if (upcomingMilestonesTarget.has(latestStableVersion + 1)) { - foundMilestone = latestStableVersion + 1; - this.isUpcoming = true; - } else if (upcomingMilestonesTarget.has(latestStableVersion + 2)) { - foundMilestone = latestStableVersion + 2; - this.isUpcoming = true; - } - - if (this.isUpcoming) { - Object.keys(channels).forEach(key => { - if (channels[key].version === foundMilestone) { - this.closestShippingDate = channels[key].final_beta; - } - }); - } else { - const shippedMilestonesTarget = shippingTypeMilestones; - // If not upcoming, find the closest milestone that has shipped. - let latestMilestone = 0; - for (const ms of shippedMilestonesTarget) { - if (ms && ms <= latestStableVersion) { - latestMilestone = Math.max(latestMilestone, ms); - } - } - - if (latestMilestone === latestStableVersion) { - this.closestShippingDate = channels['stable']?.final_beta; - this.hasShipped = true; - } else { - this.closestShippingDate = - await this.fetchClosestShippingDate(latestMilestone); - this.hasShipped = true; - } - } - } - - /** - * Determine if it should show warnings to a feature author, if - * a shipped feature is outdated, and it has edit access.*/ - isShippedFeatureOutdatedForAuthor() { - return this.userCanEdit() && this.isShippedFeatureOutdated(); - } - - /** - * Determine if it should show warnings to all readers, if - * a shipped feature is outdated, and last update was > 2 months.*/ - isShippedFeatureOutdatedForAll() { - if (!this.isShippedFeatureOutdated()) { - return false; - } - - // Represent two months grace period. - const nineWeekPeriod = 9 * 7 * 24 * 60 * 60 * 1000; - const isVerified = isVerifiedWithinGracePeriod( - this.feature.accurate_as_of, - this.currentDate, - nineWeekPeriod - ); - return !isVerified; - } - - /** - * A feature is outdated if it has shipped, and its - * accurate_as_of is before its latest shipping date before today.*/ - isShippedFeatureOutdated(): boolean { - // Check if a feature has shipped. - if (!this.hasShipped) { - return false; - } - - // If accurate_as_of is missing from a shipped feature, it is likely - // an old feature. Treat it as not oudated. - if (!this.feature.accurate_as_of) { - return false; - } - - return ( - Date.parse(this.feature.accurate_as_of) < - Date.parse(this.closestShippingDate) - ); - } - - /** - * A feature is outdated if it is scheduled to ship in the next 2 milestones, - * and its accurate_as_of date is at least 4 weeks ago.*/ - isUpcomingFeatureOutdated(): boolean { - if (!this.isUpcoming) { - return false; - } - - const isVerified = isVerifiedWithinGracePeriod( - this.feature.accurate_as_of, - this.currentDate - ); - return !isVerified; - } - fetchData() { this.loading = true; Promise.all([ @@ -295,7 +151,7 @@ export class ChromedashFeaturePage extends LitElement { window.csClient.getChannels(), ]) .then( - ([ + async ([ feature, gatesRes, commentRes, @@ -315,7 +171,11 @@ export class ChromedashFeaturePage extends LitElement { if (this.feature.name) { document.title = `${this.feature.name} - ${this.appTitle}`; } - this.findClosestShippingDate(channels, feature.stages); + this.shippingInfo = await findClosestShippingDate( + channels, + feature.stages + ); + this.loading = false; } ) @@ -632,103 +492,17 @@ export class ChromedashFeaturePage extends LitElement { `); } - if (this.isUpcomingFeatureOutdated()) { - if (this.userCanEdit()) { - warnings.push(html` -
- - - - - Your feature hasn't been verified as accurate since  - , but it is scheduled to ship  - . Please - verify that your feature is accurate. - -
- `); - } else { - warnings.push(html` -
- - - - - This feature hasn't been verified as accurate since  - , but it is scheduled to ship  - . - -
- `); - } + const userCanEdit = this.userCanEdit(); + const featureOutdatedBanner = getFeatureOutdatedBanner( + this.feature, + this.shippingInfo, + this.currentDate, + userCanEdit + ); + if (featureOutdatedBanner) { + warnings.push(featureOutdatedBanner); } - if (this.isShippedFeatureOutdated()) { - if (this.isShippedFeatureOutdatedForAuthor()) { - warnings.push(html` -
- - - - - Your feature hasn't been verified as accurate since  - , but it claims to have shipped  - . Please - verify that your feature is accurate. - -
- `); - } else if (this.isShippedFeatureOutdatedForAll()) { - warnings.push(html` -
- - - - - This feature hasn't been verified as accurate since  - , but it claims to have shipped  - . - -
- `); - } - } return warnings; } diff --git a/client-src/elements/chromedash-feature-page_test.ts b/client-src/elements/chromedash-feature-page_test.ts index dd58a3cde5fe..52c3ea835790 100644 --- a/client-src/elements/chromedash-feature-page_test.ts +++ b/client-src/elements/chromedash-feature-page_test.ts @@ -6,6 +6,7 @@ import {ChromedashFeaturePage} from './chromedash-feature-page'; import {ChromedashLink} from './chromedash-link'; import './chromedash-toast'; import {ChromedashVendorViews} from './chromedash-vendor-views'; +import {findClosestShippingDate} from './utils'; describe('chromedash-feature-page', () => { const user = { @@ -223,7 +224,7 @@ describe('chromedash-feature-page', () => { const contextLink = '/features'; window.csClient.getFeature.withArgs(featureId).returns(validFeaturePromise); - const component = await fixture( + const component = await fixture( html` { > ` ); - assert.exists(component); + + await component.updateComplete; + + assert.exists(component, 'Feature page exists.'); const subheaderDiv = component.shadowRoot?.querySelector('div#subheader'); - assert.exists(subheaderDiv); + assert.exists(subheaderDiv, 'Subheader div exists.'); // crbug link is clickable assert.include(subheaderDiv.innerHTML, 'href="fake crbug link"'); // star icon is rendered and the feature is starred assert.include(subheaderDiv.innerHTML, 'name="star-fill"'); const breadcrumbsH2 = component.shadowRoot?.querySelector('h2#breadcrumbs'); - assert.exists(breadcrumbsH2); + assert.exists(breadcrumbsH2, 'Breadcrumbs exist.'); // feature name is rendered assert.include(breadcrumbsH2.innerHTML, 'feature one'); // context link is clickable @@ -252,16 +256,16 @@ describe('chromedash-feature-page', () => { const highlightsElement = component.shadowRoot?.querySelector( 'chromedash-feature-highlights' ); - assert.exists(highlightsElement); + assert.exists(highlightsElement, 'Highlights element exists.'); const summarySection = highlightsElement.shadowRoot?.querySelector('section#summary'); - assert.exists(summarySection); + assert.exists(summarySection, 'Summary section exists.'); // feature summary is rendered assert.include(summarySection.innerHTML, 'detailed sum'); const sampleSection = highlightsElement.shadowRoot?.querySelector('section#demo'); - assert.exists(sampleSection); + assert.exists(sampleSection, 'Sample section exists.'); // sample links are clickable assert.include(sampleSection.innerHTML, 'href="fake sample link one"'); assert.include(sampleSection.innerHTML, 'href="fake sample link two"'); @@ -269,7 +273,7 @@ describe('chromedash-feature-page', () => { const docSection = highlightsElement.shadowRoot?.querySelector( 'section#documentation' ); - assert.exists(docSection); + assert.exists(docSection, 'Docs section exists.'); // doc links are clickable assert.include(docSection.innerHTML, 'href="fake doc link one"'); assert.include(docSection.innerHTML, 'href="fake doc link two"'); @@ -277,14 +281,14 @@ describe('chromedash-feature-page', () => { const specSection = highlightsElement.shadowRoot?.querySelector( 'section#specification' ); - assert.exists(specSection); + assert.exists(specSection, 'Spec section exists.'); // spec link is clickable assert.include(specSection.innerHTML, 'href="fake spec link"'); const consensusSection = highlightsElement.shadowRoot?.querySelector( 'section#consensus' ) as HTMLElement; - assert.exists(consensusSection); + assert.exists(consensusSection, 'Consensus section exists.'); // FF and WebKit views are present and clickable. await assertClickableVendorLink(consensusSection, { href: 'fake ff url', @@ -298,7 +302,7 @@ describe('chromedash-feature-page', () => { const tagSection = highlightsElement.shadowRoot?.querySelector('section#tags'); - assert.exists(tagSection); + assert.exists(tagSection, 'TAG section exists.'); // feature tag link is clickable assert.include(tagSection.innerHTML, 'href="/features#tags:tag_one"'); }); @@ -313,7 +317,7 @@ describe('chromedash-feature-page', () => { .withArgs(featureId) .returns(Promise.resolve(features)); - const component = await fixture( + const component = await fixture( html` { > ` ); - assert.exists(component); + + await component.updateComplete; + + assert.exists(component, 'Feature page exists.'); const highlightsElement = component.shadowRoot?.querySelector( 'chromedash-feature-highlights' ); - assert.exists(highlightsElement); + assert.exists(highlightsElement, 'Highlights element exists.'); const consensusSection = highlightsElement.shadowRoot?.querySelector('section#consensus'); - assert.exists(consensusSection); + assert.exists(consensusSection, 'Consensus section exists.'); // Views are omitted based on an empty 'val' field. assert.notInclude(consensusSection.innerHTML, ' { - const featureId = 123456; - const contextLink = '/features'; - const feature: any = structuredClone(await validFeaturePromise); - const component: ChromedashFeaturePage = - await fixture( - html` - ` - ); - assert.exists(component); - - component.findClosestShippingDate({}, feature.stages); - assert.isFalse(component.isUpcoming); - assert.equal(component.closestShippingDate, ''); - - component.findClosestShippingDate(channels, []); - assert.isFalse(component.isUpcoming); - assert.equal(component.closestShippingDate, ''); - - // No shipping milestones. - let stages: any = structuredClone(feature.stages); - stages[2].stage_type = 130; - component.findClosestShippingDate(channels, stages); - assert.isFalse(component.isUpcoming); - assert.equal(component.closestShippingDate, ''); - - // No upcoming shipping milestones. - stages = structuredClone(feature.stages); - stages[2].desktop_first = 20; - component.findClosestShippingDate(channels, stages); - assert.isFalse(component.isUpcoming); - assert.isFalse(component.hasShipped); - assert.equal(component.closestShippingDate, ''); - - component.findClosestShippingDate(channels, feature.stages); - assert.isTrue(component.isUpcoming); - assert.isFalse(component.hasShipped); - assert.equal(component.closestShippingDate, '2020-03-13T00:00:00'); - - component.closestShippingDate = ''; - component.isUpcoming = false; - stages = structuredClone(feature.stages); - // Test with STAGE_BLINK_ORIGIN_TRIAL type. - stages[2].stage_type = 150; - component.findClosestShippingDate(channels, stages); - assert.isTrue(component.isUpcoming); - assert.isFalse(component.hasShipped); - assert.equal(component.closestShippingDate, '2020-03-13T00:00:00'); - }); - - it('findClosestShippingDate() tests for hasShipped state', async () => { - const featureId = 123456; - const contextLink = '/features'; - const feature: any = structuredClone(await validFeaturePromise); - const component: ChromedashFeaturePage = - await fixture( - html` - ` - ); - assert.exists(component); - - component.findClosestShippingDate({}, feature.stages); - assert.isFalse(component.hasShipped); - assert.equal(component.closestShippingDate, ''); - - component.findClosestShippingDate(channels, []); - assert.isFalse(component.hasShipped); - assert.equal(component.closestShippingDate, ''); - - // No shipping milestones. - let stages: any = structuredClone(feature.stages); - stages[2].stage_type = 130; - component.findClosestShippingDate(channels, stages); - assert.isFalse(component.hasShipped); - assert.equal(component.closestShippingDate, ''); - - // No shipped milestones in the past. - const testChannels: any = structuredClone(channels); - testChannels['stable'].version = 10; - component.findClosestShippingDate(testChannels, stages); - assert.isFalse(component.hasShipped); - assert.equal(component.closestShippingDate, ''); - - // Shipped on the stable milestone. - stages = structuredClone(feature.stages); - stages[2].desktop_first = 79; - component.findClosestShippingDate(channels, stages); - assert.isFalse(component.isUpcoming); - assert.isTrue(component.hasShipped); - assert.equal(component.closestShippingDate, '2020-03-13T00:00:00'); - - component.isUpcoming = false; - component.hasShipped = false; - component.closestShippingDate = ''; - // Ignore OT milestones in the past. - stages = structuredClone(feature.stages); - stages[2].desktop_first = 79; - // The type for STAGE_BLINK_ORIGIN_TRIAL. - stages[2].stage_type = 150; - component.findClosestShippingDate(channels, stages); - assert.isFalse(component.isUpcoming); - assert.isFalse(component.hasShipped); - assert.equal(component.closestShippingDate, ''); - }); - - it('findClosestShippingDate() tests when fetch specific channels', async () => { - const featureId = 123456; - const contextLink = '/features'; - const feature: any = structuredClone(await validFeaturePromise); - feature.stages[2].desktop_first = 20; - window.csClient.getFeature - .withArgs(featureId) - .returns(Promise.resolve(feature)); - - const component: ChromedashFeaturePage = - await fixture( - html` - ` - ); - - assert.exists(component); - assert.isFalse(component.isUpcoming); - assert.isTrue(component.hasShipped); - assert.equal(component.closestShippingDate, '2018-02-13T00:00:00'); - }); - - it('isUpcomingFeatureOutdated() tests', async () => { - const featureId = 123456; - const contextLink = '/features'; - const feature: any = structuredClone(await validFeaturePromise); - feature.accurate_as_of = '2024-08-28 21:51:34.22386'; - window.csClient.getFeature - .withArgs(featureId) - .returns(Promise.resolve(feature)); - const component: ChromedashFeaturePage = - await fixture( - html` - ` - ); - component.currentDate = new Date('2024-10-23').getTime(); - assert.exists(component); - - component.findClosestShippingDate(channels, feature.stages); - assert.isTrue(component.isUpcoming); - assert.equal(component.closestShippingDate, '2020-03-13T00:00:00'); - assert.isTrue(component.isUpcomingFeatureOutdated()); - - // accurate_as_of is not outdated and within the 4-week grace period. - component.currentDate = new Date('2024-09-18').getTime(); - assert.isFalse(component.isUpcomingFeatureOutdated()); - }); - - it('render the oudated warning when outdated', async () => { - const featureId = 123456; - const contextLink = '/features'; - const feature: any = structuredClone(await validFeaturePromise); - feature.accurate_as_of = '2024-08-28 21:51:34.22386'; - window.csClient.getFeature - .withArgs(featureId) - .returns(Promise.resolve(feature)); - const component: ChromedashFeaturePage = - await fixture( - html` - ` - ); - component.currentDate = new Date('2024-10-23').getTime(); - assert.exists(component); - - component.findClosestShippingDate(channels, feature.stages); - const oudated = component.shadowRoot!.querySelector('#outdated-icon'); - assert.exists(oudated); - }); - it('render shipped feature outdated warnings for authors', async () => { const featureId = 123456; const contextLink = '/features'; @@ -553,9 +367,6 @@ describe('chromedash-feature-page', () => { await component.updateComplete; assert.exists(component); - assert.isTrue(component.isShippedFeatureOutdated()); - assert.isTrue(component.isShippedFeatureOutdatedForAuthor()); - assert.isFalse(component.isShippedFeatureOutdatedForAll()); const oudated = component.shadowRoot!.querySelector( '#shipped-outdated-author' ); @@ -584,11 +395,6 @@ describe('chromedash-feature-page', () => { component.currentDate = new Date('2020-10-29').getTime(); await component.updateComplete; assert.exists(component); - - assert.isTrue(component.isShippedFeatureOutdated()); - // undefined because this.user is undefined. - assert.isUndefined(component.isShippedFeatureOutdatedForAuthor()); - assert.isTrue(component.isShippedFeatureOutdatedForAll()); const oudated = component.shadowRoot!.querySelector( '#shipped-outdated-all' ); diff --git a/client-src/elements/chromedash-roadmap-milestone-card_test.ts b/client-src/elements/chromedash-roadmap-milestone-card_test.ts index 28855599de87..5898ffe317de 100644 --- a/client-src/elements/chromedash-roadmap-milestone-card_test.ts +++ b/client-src/elements/chromedash-roadmap-milestone-card_test.ts @@ -8,14 +8,254 @@ import { import {FEATURE_TYPES} from './form-field-enums'; describe('chromedash-roadmap-milestone-card', () => { + const baseFeature = { + id: 1001, + created: { + by: 'creator@example.com', + when: '2025-01-01T00:00:00Z', + }, + updated: { + by: 'updater@example.com', + when: '2025-10-20T12:00:00Z', + }, + accurate_as_of: '2025-10-20T12:00:00Z', + creator_email: 'creator@example.com', + updater_email: 'updater@example.com', + + owner_emails: ['owner1@example.com', 'owner2@example.com'], + editor_emails: ['editor1@example.com'], + cc_emails: ['cc1@example.com', 'cc2@example.com'], + spec_mentor_emails: ['mentor1@example.com'], + unlisted: false, + confidential: false, + deleted: false, + + editors: ['editor1@example.com'], + cc_recipients: ['cc1@example.com', 'cc2@example.com'], + spec_mentors: ['mentor1@example.com'], + creator: 'creator@example.com', + + name: 'Test Feature', + summary: 'This is a complete summary for the test feature.', + markdown_fields: ['summary', 'motivation', 'feature_notes'], + category: 'Web Components', + category_int: 1, + web_feature: 'New Custom Element Lifecycle', + blink_components: ['Blink>WebComponents'], + star_count: 42, + search_tags: ['web-components', 'custom-elements', 'test'], + feature_notes: 'Some internal notes about this feature.', + enterprise_feature_categories: ['security', 'productivity'], + enterprise_product_category: 2, + + feature_type: 'Incubation', + feature_type_int: 0, + intent_stage: 'Prototype', + intent_stage_int: 2, + active_stage_id: 201, + bug_url: 'https://bugs.example.com/12345', + launch_bug_url: 'https://bugs.example.com/launch/67890', + screenshot_links: [ + 'https://example.com/screenshot1.png', + 'https://example.com/screenshot2.png', + ], + first_enterprise_notification_milestone: 120, + breaking_change: false, + enterprise_impact: 2, + + flag_name: 'EnableTestFeature', + finch_name: 'TestFeatureFinchRollout', + non_finch_justification: 'Not applicable, will use Finch.', + ongoing_constraints: 'Requires specific hardware for full functionality.', + + motivation: 'To provide developers with more control over custom elements.', + devtrial_instructions: 'Enable the #EnableTestFeature flag in about:flags.', + activation_risks: 'Low risk of activation issues.', + measurement: 'Usage will be monitored via UMA counters.', + availability_expectation: 'Expected to be available in M125.', + adoption_expectation: 'High adoption expected from framework authors.', + adoption_plan: 'Blog post on web.dev and tutorials.', + + initial_public_proposal_url: 'https://discourse.example.com/t/123', + explainer_links: ['https://example.com/explainer.md'], + requires_embedder_support: false, + spec_link: 'https://example.com/spec.html', + api_spec: 'Yes', + prefixed: false, + interop_compat_risks: 'Some minor interop risks identified with Safari.', + all_platforms: true, + all_platforms_descr: 'Basic description.', + tag_review: 'TAG review requested, see link: https://example.com/tag/1', + non_oss_deps: 'No non-OSS dependencies.', + anticipated_spec_changes: 'Minor spec changes expected after TAG review.', + + security_risks: 'No major security risks identified.', + tags: ['security', 'privacy'], + tag_review_status: 'Pending', + tag_review_status_int: 1, + security_review_status: 'Pending', + security_review_status_int: 1, + privacy_review_status: 'Pending', + privacy_review_status_int: 1, + security_continuity_id: 9001, + security_launch_issue_id: 9002, + + ergonomics_risks: + 'Potential ergonomics risk for developers if API is misused.', + wpt: true, + wpt_descr: 'WPTs are being written in parallel.', + webview_risks: 'Low risk for WebView.', + + devrel_emails: ['devrel1@example.com', 'devrel2@example.com'], + debuggability: 'Feature is debuggable via DevTools.', + doc_links: ['https://example.com/docs/test-feature'], + sample_links: ['https://example.com/samples/test-feature'], + + stages: [ + { + id: 201, + created: '2025-02-01T00:00:00Z', + feature_id: 1001, + stage_type: 150, + display_name: 'Prototype Stage', + intent_stage: 2, + intent_thread_url: 'https://example.com/intent-to-prototype', + announcement_url: 'https://example.com/prototype-announced', + origin_trial_id: 'OT_TestFeature_123', + experiment_goals: 'Gather feedback on API shape and usability.', + experiment_risks: 'Low risk, API is behind a flag.', + extensions: [], + origin_trial_feedback_url: 'https://example.com/ot-feedback', + ot_action_requested: true, + ot_activation_date: '2025-11-01T00:00:00Z', + ot_approval_buganizer_component: 12345, + ot_approval_buganizer_custom_field_id: 67890, + ot_approval_criteria_url: 'https://example.com/ot-criteria', + ot_approval_group_email: 'ot-approvers@example.com', + ot_chromium_trial_name: 'TestFeatureOriginTrial', + ot_description: 'An Origin Trial for the Test Feature.', + ot_display_name: 'Test Feature OT', + ot_documentation_url: 'https://example.com/ot-docs', + ot_emails: ['ot-admin@example.com', 'owner1@example.com'], + ot_feedback_submission_url: 'https://example.com/ot-submit', + ot_has_third_party_support: false, + ot_is_critical_trial: false, + ot_is_deprecation_trial: false, + ot_owner_email: 'ot-owner@example.com', + ot_require_approvals: true, + ot_setup_status: 1, + ot_request_note: 'Please approve this OT for M124.', + ot_stage_id: 201, + ot_use_counter_bucket_number: 5001, + experiment_extension_reason: 'Need more time for feedback.', + finch_url: 'https://example.com/finch-rollout', + rollout_details: 'Rolling out to 1% of users.', + rollout_milestone: 124, + rollout_platforms: ['Desktop', 'Android'], + enterprise_policies: ['TestFeatureEnabled', 'LegacyFeatureDisabled'], + pm_emails: ['pm@example.com'], + tl_emails: ['tl@example.com'], + ux_emails: ['ux@example.com'], + te_emails: ['te@example.com'], + desktop_first: 124, + android_first: 124, + ios_first: 125, + webview_first: 124, + desktop_last: 126, + android_last: 126, + ios_last: 127, + webview_last: 126, + }, + ], + + resources: { + samples: ['https://example.com/sample1', 'https://example.com/sample2'], + docs: ['https://example.com/doc1', 'https://example.com/doc2'], + }, + comments: 'This is a test feature with all fields filled.', + + ff_views: 1, + safari_views: 2, + web_dev_views: 1, + + browsers: { + chrome: { + bug: 'https://bugs.example.com/chrome/123', + blink_components: ['Blink>WebComponents'], + devrel: ['devrel1@example.com'], + owners: ['owner1@example.com'], + origintrial: true, + intervention: false, + prefixed: false, + flag: true, + status: { + text: 'In development', + val: 1, + milestone_str: '124', + }, + desktop: 124, + android: 124, + webview: 124, + ios: 125, + }, + ff: { + view: { + text: 'Positive', + val: 1, + url: 'https://example.com/ff-position', + notes: 'Firefox is supportive of the goals.', + }, + }, + safari: { + view: { + text: 'Neutral', + val: 2, + url: 'https://example.com/safari-position', + notes: 'Safari is waiting for more feedback.', + }, + }, + webdev: { + view: { + text: 'Positive', + val: 1, + url: 'https://example.com/webdev-position', + notes: 'Web developers are excited.', + }, + }, + other: { + view: { + text: 'No signal', + val: 0, + url: '', + notes: 'No signals from other engines.', + }, + }, + }, + + standards: { + spec: 'https://example.com/spec.html', + maturity: { + text: 'Early Draft', + short_text: 'Draft', + val: 1, + }, + }, + + is_released: false, + is_enterprise_feature: true, + updated_display: 'Oct 20, 2025', + new_crbug_url: 'https://crbug.com/new/component=Blink>WebComponents', + }; + const mockFeature = { + ...baseFeature, id: 134, name: 'vmvvv', summary: 'd', unlisted: false, enterprise_impact: 1, breaking_change: false, - first_enterprise_notification_milestone: null, + first_enterprise_notification_milestone: undefined, blink_components: ['Blink>CaptureFromElement'], resources: { samples: [], @@ -31,22 +271,22 @@ describe('chromedash-roadmap-milestone-card', () => { }, accurate_as_of: '2024-08-28 21:51:34.223867', standards: { - spec: null, + spec: undefined, maturity: { - text: null, + text: undefined, short_text: 'Unknown status', val: 0, }, }, browsers: { chrome: { - bug: null, + bug: undefined, blink_components: ['Blink>CaptureFromElement'], devrel: ['devrel-chromestatus-all@google.com'], owners: ['example@chromium.org'], origintrial: false, intervention: false, - prefixed: null, + prefixed: false, flag: false, status: { text: 'No active development', @@ -55,16 +295,16 @@ describe('chromedash-roadmap-milestone-card', () => { }, ff: { view: { - url: null, - notes: null, + url: undefined, + notes: undefined, text: 'No signal', val: 5, }, }, safari: { view: { - url: null, - notes: null, + url: undefined, + notes: undefined, text: 'No signal', val: 5, }, @@ -73,18 +313,18 @@ describe('chromedash-roadmap-milestone-card', () => { view: { text: 'No signals', val: 4, - url: null, - notes: null, + url: undefined, + notes: undefined, }, }, other: { view: { - notes: null, + notes: undefined, }, }, }, is_released: false, - milestone: null, + milestone: undefined, }; const stableMilestone: number = 107; @@ -98,7 +338,7 @@ describe('chromedash-roadmap-milestone-card', () => { const channel: ReleaseInfo = { version: 108, early_stable: '2020-02 - 13T00:00:00', - features: {} as Feature, + features: {}, }; channel.features['Origin trial'] = [mockFeature]; @@ -179,8 +419,8 @@ describe('chromedash-roadmap-milestone-card', () => { assert.isNull(oudated); }); - it('not renders the oudated icon when accurate_as_of is null', async () => { - channel.features['Origin trial'][0]['accurate_as_of'] = null; + it('not renders the oudated icon when accurate_as_of is undefined', async () => { + channel.features['Origin trial'][0]['accurate_as_of'] = undefined; channel.version = stableMilestone + 3; const el: ChromedashRoadmapMilestoneCard = await fixture( diff --git a/client-src/elements/form-definition.ts b/client-src/elements/form-definition.ts index 437ff39e76f5..a49e66f8a9e6 100644 --- a/client-src/elements/form-definition.ts +++ b/client-src/elements/form-definition.ts @@ -22,7 +22,7 @@ export interface FormattedFeature { devrel?: string[]; owner?: string[]; prefixed?: boolean; - impl_status_chrome?: string; + impl_status_chrome?: number; shipped_milestone?: number; shipped_android_milestone?: number; shipped_webview_milestone?: number; diff --git a/client-src/elements/utils.ts b/client-src/elements/utils.ts index 5df313171730..74c19523331f 100644 --- a/client-src/elements/utils.ts +++ b/client-src/elements/utils.ts @@ -4,7 +4,12 @@ import {html, nothing, TemplateResult} from 'lit'; import {unsafeHTML} from 'lit/directives/unsafe-html.js'; import DOMPurify from 'dompurify'; import {marked} from 'marked'; -import {Feature, FeatureLink, StageDict} from '../js-src/cs-client.js'; +import { + Channels, + Feature, + FeatureLink, + StageDict, +} from '../js-src/cs-client.js'; import {markupAutolinks} from './autolink.js'; import {FORMS_BY_STAGE_TYPE, FormattedFeature} from './form-definition.js'; import { @@ -17,6 +22,8 @@ import { ROLLOUT_PLAN_DISPLAYNAME, STAGE_FIELD_NAME_MAPPING, STAGE_SPECIFIC_FIELDS, + STAGE_TYPES_ORIGIN_TRIAL, + STAGE_TYPES_SHIPPING, } from './form-field-enums'; let toastEl; @@ -29,6 +36,9 @@ const NARROW_WINDOW_MAX_WIDTH = 700; // to be consistent with ACCURACY_GRACE_PERIOD in internals/reminders.py. const ACCURACY_GRACE_PERIOD = 4 * 7 * 24 * 60 * 60 * 1000; +// A 9-week grace period used to approximate 2 months for shipped features. +const SHIPPED_FEATURE_OUTDATED_GRACE_PERIOD = 9 * 7 * 24 * 60 * 60 * 1000; + export const IS_MOBILE = (() => { const width = window.innerWidth || @@ -742,3 +752,317 @@ export const METRICS_TYPE_AND_VIEW_TO_SUBTITLE = { featurepopularity: 'HTML & JavaScript usage metrics > all features', webfeaturepopularity: 'Web features usage metrics > all features', }; + +/** + * A feature is outdated if it has shipped, and its + * accurate_as_of is before its latest shipping date before today. + */ +function isShippedFeatureOutdated( + feature: Feature, + hasShipped: boolean, + closestShippingDate: string +): boolean { + // Check if a feature has shipped. + if (!hasShipped) { + return false; + } + + // If accurate_as_of is missing from a shipped feature, it is likely + // an old feature. Treat it as not oudated. + if (!feature.accurate_as_of || !closestShippingDate) { + return false; + } + + return Date.parse(feature.accurate_as_of) < Date.parse(closestShippingDate); +} + +/** + * Determine if it should show warnings to a feature author, if + * a shipped feature is outdated, and it has edit access. + */ +function isShippedFeatureOutdatedForAuthor( + feature: Feature, + userCanEdit: boolean, + hasShipped: boolean, + closestShippingDate: string +): boolean { + return ( + userCanEdit && + isShippedFeatureOutdated(feature, hasShipped, closestShippingDate) + ); +} + +/** + * Determine if it should show warnings to all readers, if + * a shipped feature is outdated, and last update was > 9 weeks. + */ +function isShippedFeatureOutdatedForAll( + feature: Feature, + hasShipped: boolean, + currentDate: number, + closestShippingDate: string +): boolean { + if (!isShippedFeatureOutdated(feature, hasShipped, closestShippingDate)) { + return false; + } + + const isVerified = isVerifiedWithinGracePeriod( + feature.accurate_as_of, + currentDate, + SHIPPED_FEATURE_OUTDATED_GRACE_PERIOD + ); + return !isVerified; +} + +/** + * Fetches the shipping date (final_beta) for a specific milestone. + */ +async function fetchClosestShippingDate(milestone: number): Promise { + if (milestone === 0) { + return ''; + } + try { + const newMilestonesInfo = await window.csClient.getSpecifiedChannels( + milestone, + milestone + ); + return newMilestonesInfo[milestone]?.final_beta; + } catch { + showToastMessage( + 'Some errors occurred. Please refresh the page or try again later.' + ); + return ''; + } +} + +export interface closestShippingDateInfo { + // The closest milestone shipping date as an ISO string. + closestShippingDate: string; + isUpcoming: boolean; + hasShipped: boolean; +} + +/** + * Determine if this feature is upcoming - scheduled to ship + * within two milestones, then find the closest shipping date + * for that upcoming milestone or an already shipped milestone. + */ +export async function findClosestShippingDate( + channels: Channels, + stages: StageDict[] +): Promise { + const latestStableVersion = channels?.stable?.version; + if (!latestStableVersion || !stages) { + return { + closestShippingDate: '', + isUpcoming: false, + hasShipped: false, + }; + } + + let closestShippingDate = ''; + let isUpcoming = false; + let hasShipped = false; + + const shippingTypeMilestones = new Set(); + const otTypeMilestones = new Set(); + for (const stage of stages) { + if (STAGE_TYPES_SHIPPING.has(stage.stage_type)) { + shippingTypeMilestones.add(stage.desktop_first); + shippingTypeMilestones.add(stage.android_first); + shippingTypeMilestones.add(stage.ios_first); + shippingTypeMilestones.add(stage.webview_first); + } + } + for (const stage of stages) { + if (STAGE_TYPES_ORIGIN_TRIAL.has(stage.stage_type)) { + otTypeMilestones.add(stage.desktop_first); + otTypeMilestones.add(stage.android_first); + otTypeMilestones.add(stage.ios_first); + otTypeMilestones.add(stage.webview_first); + } + } + + const upcomingMilestonesTarget = new Set([ + ...shippingTypeMilestones, + ...otTypeMilestones, + ]); + // Check if this feature is shipped within two milestones. + let foundMilestone = 0; + if (upcomingMilestonesTarget.has(latestStableVersion + 1)) { + foundMilestone = latestStableVersion + 1; + isUpcoming = true; + } else if (upcomingMilestonesTarget.has(latestStableVersion + 2)) { + foundMilestone = latestStableVersion + 2; + isUpcoming = true; + } + + if (isUpcoming) { + Object.keys(channels).forEach(key => { + if (channels[key].version === foundMilestone) { + closestShippingDate = channels[key].final_beta || ''; + } + }); + } else { + const shippedMilestonesTarget = shippingTypeMilestones; + // If not upcoming, find the closest milestone that has shipped. + let latestMilestone = 0; + for (const ms of shippedMilestonesTarget) { + if (ms && ms <= latestStableVersion) { + latestMilestone = Math.max(latestMilestone, ms); + } + } + + if (latestMilestone === latestStableVersion) { + closestShippingDate = channels['stable']?.final_beta || ''; + hasShipped = true; + } else { + closestShippingDate = await fetchClosestShippingDate(latestMilestone); + hasShipped = true; + } + } + return { + closestShippingDate, + isUpcoming, + hasShipped, + }; +} + +/** + * A feature is outdated if it is scheduled to ship in the next 2 milestones, + * and its accurate_as_of date is at least 4 weeks ago. + */ +function isUpcomingFeatureOutdated( + feature: Feature, + isUpcoming: boolean, + currentDate: number +): boolean { + return ( + isUpcoming && + !isVerifiedWithinGracePeriod(feature.accurate_as_of, currentDate) + ); +} + +/** + * Returns a warning banner TemplateResult if a feature is considered outdated, + * otherwise returns null. + * @param {Feature} feature The feature object. + * @param {closestShippingDateInfo} shippingInfo The shipping information from findClosestShippingDate. + * @param {number | undefined} currentDate The current date as a timestamp. + * @param {boolean} userCanEdit Whether the current user can edit the feature. + * @returns {TemplateResult | null} A lit-html template or null. + */ +export function getFeatureOutdatedBanner( + feature: Feature, + shippingInfo: closestShippingDateInfo, + currentDate: number | undefined, + userCanEdit: boolean +): TemplateResult | null { + if (!currentDate) { + currentDate = Date.now(); + } + const {closestShippingDate, hasShipped, isUpcoming} = shippingInfo; + if (isUpcomingFeatureOutdated(feature, isUpcoming, currentDate)) { + if (userCanEdit) { + return html` +
+ + + + + Your feature hasn't been verified as accurate since  + , but it is scheduled to ship  + . + Please + verify that your feature is accurate. + +
+ `; + } else { + return html` +
+ + + + + This feature hasn't been verified as accurate since  + , but it is scheduled to ship  + . + +
+ `; + } + } else if ( + isShippedFeatureOutdated(feature, hasShipped, closestShippingDate) + ) { + if ( + isShippedFeatureOutdatedForAuthor( + feature, + userCanEdit, + hasShipped, + closestShippingDate + ) + ) { + return html` +
+ + + + + Your feature hasn't been verified as accurate since  + , but it claims to have shipped  + . + Please + verify that your feature is accurate. + +
+ `; + } else if ( + isShippedFeatureOutdatedForAll( + feature, + hasShipped, + currentDate, + closestShippingDate + ) + ) { + return html` +
+ + + + + This feature hasn't been verified as accurate since  + , but it claims to have shipped  + . + +
+ `; + } + } + + return null; +} diff --git a/client-src/elements/utils_test.ts b/client-src/elements/utils_test.ts index 20933a063608..15b8b1536765 100644 --- a/client-src/elements/utils_test.ts +++ b/client-src/elements/utils_test.ts @@ -2,11 +2,15 @@ import {html} from 'lit'; import { autolink, clamp, + findClosestShippingDate, formatFeatureChanges, getDisabledHelpText, + getFeatureOutdatedBanner, + isVerifiedWithinGracePeriod, } from './utils'; import {assert} from '@open-wc/testing'; import {OT_SETUP_STATUS_OPTIONS} from './form-field-enums'; +import {Channels, Feature} from '../js-src/cs-client'; const compareAutolinkResult = (result, expected) => { assert.equal(result.length, expected.length); @@ -19,6 +23,21 @@ const compareAutolinkResult = (result, expected) => { } }; +/** + * Helper to identify which banner is returned by checking the ID + * of the first element with an ID. + * @param {TemplateResult | null} result + * @returns {string | null} The ID of the banner, or null. + */ +const getBannerId = result => { + if (!result) return null; + // A lit TemplateResult has a 'strings' array. + // We can find the ID in the strings. + const htmlString = result.strings.join(''); + const match = htmlString.match(/id="([^"]+)"/); + return match ? match[1] : null; +}; + // prettier-ignore describe('utils', () => { describe('autolink', () => { @@ -346,4 +365,622 @@ go/this-is-a-test assert.equal(result, ''); }); }); + +describe('isVerifiedWithinGracePeriod', () => { + const GRACE_PERIOD = 4 * 7 * 24 * 60 * 60 * 1000; // 4 weeks + const CURRENT_DATE = Date.parse('2025-03-01T00:00:00Z'); + + it('should return false if accurateAsOf is undefined', () => { + assert.isFalse(isVerifiedWithinGracePeriod(undefined, CURRENT_DATE)); + }); + + it('should return true if within the grace period', () => { + // 2 weeks ago + const accurateAsOf = '2025-02-15T00:00:00Z'; + assert.isTrue(isVerifiedWithinGracePeriod(accurateAsOf, CURRENT_DATE)); + }); + + it('should return false if outside the grace period', () => { + // 5 weeks ago + const accurateAsOf = '2025-01-25T00:00:00Z'; + assert.isFalse(isVerifiedWithinGracePeriod(accurateAsOf, CURRENT_DATE)); + }); + + it('should return true if exactly on the edge of the grace period', () => { + // Exactly 4 weeks ago + const accurateAsOf = '2025-02-01T00:00:00Z'; + // The check is (date + grace < current), so exact match is still valid + const edgeDate = Date.parse(accurateAsOf) + GRACE_PERIOD; + assert.isTrue(isVerifiedWithinGracePeriod(accurateAsOf, edgeDate)); + }); + + it('should return false if 1ms past the grace period', () => { + const accurateAsOf = '2025-02-01T00:00:00Z'; + const edgeDate = Date.parse(accurateAsOf) + GRACE_PERIOD + 1; + assert.isFalse(isVerifiedWithinGracePeriod(accurateAsOf, edgeDate)); + }); + + it('should respect custom grace period', () => { + // 5 weeks ago + const accurateAsOf = '2025-01-25T00:00:00Z'; + const NINE_WEEKS = 9 * 7 * 24 * 60 * 60 * 1000; + // Still valid under a 9-week grace period + assert.isTrue( + isVerifiedWithinGracePeriod(accurateAsOf, CURRENT_DATE, NINE_WEEKS) + ); + // Invalid under a 2-week grace period + const TWO_WEEKS = 2 * 7 * 24 * 60 * 60 * 1000; + assert.isFalse( + isVerifiedWithinGracePeriod(accurateAsOf, CURRENT_DATE, TWO_WEEKS) + ); + }); + }); + + describe('findClosestShippingDate', () => { + const mockChannels: Channels = { + stable: { + version: 100, + earliest_beta: '2025-09-20T00:00:00Z', + mstone: '125', + stable_date: '2025-10-20T00:00:00Z', + latest_beta: '2025-10-10T00:00:00Z', + final_beta: '2025-01-01T00:00:00Z', + early_stable: '2025-10-19T00:00:00Z', + features: {}, + }, + beta: { + version: 101, + earliest_beta: '2025-10-20T00:00:00Z', + mstone: '126', + stable_date: '2025-11-20T00:00:00Z', + latest_beta: '2025-11-10T00:00:00Z', + final_beta: '2025-02-01T00:00:00Z', + early_stable: '2025-11-19T00:00:00Z', + features: {}, + }, + dev: { + version: 102, + earliest_beta: '2Next-release-TBD', + mstone: '127', + stable_date: null, + latest_beta: null, + final_beta: '2025-03-01T00:00:00Z', + early_stable: null, + features: {}, + }, + canary: { + version: 103, + mstone: '128', + stable_date: null, + latest_beta: null, + final_beta: '2025-04-01T00:00:00Z', + early_stable: null, + features: {}, + } + }; + const baseStage = { + id: 201, + created: '2025-02-01T00:00:00Z', + feature_id: 1001, + stage_type: 160, + display_name: 'Shipping Stage', + intent_stage: 2, + intent_thread_url: 'https://example.com/intent-to-prototype', + announcement_url: 'https://example.com/prototype-announced', + origin_trial_id: 'OT_TestFeature_123', + experiment_goals: 'Gather feedback on API shape and usability.', + experiment_risks: 'Low risk, API is behind a flag.', + extensions: [], + origin_trial_feedback_url: 'https://example.com/ot-feedback', + ot_action_requested: true, + ot_activation_date: '2025-11-01T00:00:00Z', + ot_approval_buganizer_component: 12345, + ot_approval_buganizer_custom_field_id: 67890, + ot_approval_criteria_url: 'https://example.com/ot-criteria', + ot_approval_group_email: 'ot-approvers@example.com', + ot_chromium_trial_name: 'TestFeatureOriginTrial', + ot_description: 'An Origin Trial for the Test Feature.', + ot_display_name: 'Test Feature OT', + ot_documentation_url: 'https://example.com/ot-docs', + ot_emails: ['ot-admin@example.com', 'owner1@example.com'], + ot_feedback_submission_url: 'https://example.com/ot-submit', + ot_has_third_party_support: false, + ot_is_critical_trial: false, + ot_is_deprecation_trial: false, + ot_owner_email: 'ot-owner@example.com', + ot_require_approvals: true, + ot_setup_status: 1, + ot_request_note: 'Please approve this OT for M124.', + ot_stage_id: 201, + ot_use_counter_bucket_number: 5001, + experiment_extension_reason: 'Need more time for feedback.', + finch_url: 'https://example.com/finch-rollout', + rollout_details: 'Rolling out to 1% of users.', + rollout_milestone: 124, + rollout_platforms: ['Desktop', 'Android'], + enterprise_policies: ['TestFeatureEnabled', 'LegacyFeatureDisabled'], + pm_emails: ['pm@example.com'], + tl_emails: ['tl@example.com'], + ux_emails: ['ux@example.com'], + te_emails: ['te@example.com'], + desktop_first: 124, + android_first: 124, + ios_first: 125, + webview_first: 124, + desktop_last: 126, + android_last: 126, + ios_last: 127, + webview_last: 126, + }; + const shippingStageType = 160; + const otStageType = 150; + + it('should identify an upcoming feature (M+1)', async () => { + const stages = [{...baseStage, stage_type: shippingStageType, desktop_first: 101}]; + const result = await findClosestShippingDate(mockChannels, stages); + assert.deepEqual(result, { + closestShippingDate: '2025-02-01T00:00:00Z', + isUpcoming: true, + hasShipped: false, + }); + }); + + it('should identify an upcoming feature (M+2)', async () => { + const stages = [{...baseStage, stage_type: shippingStageType, android_first: 102}]; + const result = await findClosestShippingDate(mockChannels, stages); + assert.deepEqual(result, { + closestShippingDate: '2025-03-01T00:00:00Z', + isUpcoming: true, + hasShipped: false, + }); + }); + + it('should identify a shipped feature (current stable)', async () => { + const stages = [{...baseStage, stage_type: shippingStageType, desktop_first: 100}]; + const result = await findClosestShippingDate(mockChannels, stages); + assert.deepEqual(result, { + closestShippingDate: '2025-01-01T00:00:00Z', + isUpcoming: false, + hasShipped: true, + }); + }); + + it('should not a shipped feature for a past milestone', async () => { + const stages = [{...baseStage, stage_type: shippingStageType, webview_first: 98}]; + const result = await findClosestShippingDate(mockChannels, stages); + assert.deepEqual(result, { + closestShippingDate: '', + isUpcoming: false, + hasShipped: true, + }); + }); + + it('should find latest shipped milestone among many', async () => { + const stages = [ + {...baseStage, stage_type: shippingStageType, desktop_first: 95}, + {...baseStage, stage_type: shippingStageType, android_first: 98}, + {...baseStage, stage_type: otStageType, ios_first: 99}, // Ignored, not a shipping type + {...baseStage, stage_type: shippingStageType, webview_first: 101}, + ]; + const result = await findClosestShippingDate(mockChannels, stages); + assert.deepEqual(result, { + closestShippingDate: '2025-02-01T00:00:00Z', + isUpcoming: true, + hasShipped: false, + }); + }); + }); + + describe('getFeatureOutdatedBanner', () => { + // 4 weeks + const FOUR_WEEKS = 4 * 7 * 24 * 60 * 60 * 1000; + // 9 weeks + const NINE_WEEKS = 9 * 7 * 24 * 60 * 60 * 1000; + // Current date: March 1, 2025 + const CURRENT_DATE_MS = Date.parse('2025-03-01T00:00:00Z'); + // Verified: Feb 15, 2025 (within 4 weeks) + const VERIFIED_DATE = new Date(CURRENT_DATE_MS - FOUR_WEEKS / 2).toISOString(); + // Outdated: Jan 15, 2025 (outside 4 weeks) + const OUTDATED_DATE = new Date( + CURRENT_DATE_MS - FOUR_WEEKS - 1000 + ).toISOString(); + // Very Outdated: Dec 1, 2024 (outside 9 weeks) + const VERY_OUTDATED_DATE = new Date( + CURRENT_DATE_MS - NINE_WEEKS - 1000 + ).toISOString(); + + const UPCOMING_SHIPPING_DATE = '2025-03-15T00:00:00Z'; + const SHIPPED_SHIPPING_DATE = '2025-02-01T00:00:00Z'; + + const baseFeature: Feature = { + id: 1001, + created: { + by: 'creator@example.com', + when: '2025-01-01T00:00:00Z', + }, + updated: { + by: 'updater@example.com', + when: '2025-10-20T12:00:00Z', + }, + accurate_as_of: '2025-10-20T12:00:00Z', + creator_email: 'creator@example.com', + updater_email: 'updater@example.com', + + owner_emails: ['owner1@example.com', 'owner2@example.com'], + editor_emails: ['editor1@example.com'], + cc_emails: ['cc1@example.com', 'cc2@example.com'], + spec_mentor_emails: ['mentor1@example.com'], + unlisted: false, + confidential: false, + deleted: false, + + editors: ['editor1@example.com'], + cc_recipients: ['cc1@example.com', 'cc2@example.com'], + spec_mentors: ['mentor1@example.com'], + creator: 'creator@example.com', + + name: 'Test Feature', + summary: 'This is a complete summary for the test feature.', + markdown_fields: ['summary', 'motivation', 'feature_notes'], + category: 'Web Components', + category_int: 1, + web_feature: 'New Custom Element Lifecycle', + blink_components: ['Blink>WebComponents'], + star_count: 42, + search_tags: ['web-components', 'custom-elements', 'test'], + feature_notes: 'Some internal notes about this feature.', + enterprise_feature_categories: ['security', 'productivity'], + enterprise_product_category: 2, + + feature_type: 'Incubation', + feature_type_int: 0, + intent_stage: 'Prototype', + intent_stage_int: 2, + active_stage_id: 201, + bug_url: 'https://bugs.example.com/12345', + launch_bug_url: 'https://bugs.example.com/launch/67890', + screenshot_links: ['https://example.com/screenshot1.png', 'https://example.com/screenshot2.png'], + first_enterprise_notification_milestone: 120, + breaking_change: false, + enterprise_impact: 2, + + flag_name: 'EnableTestFeature', + finch_name: 'TestFeatureFinchRollout', + non_finch_justification: 'Not applicable, will use Finch.', + ongoing_constraints: 'Requires specific hardware for full functionality.', + + motivation: 'To provide developers with more control over custom elements.', + devtrial_instructions: 'Enable the #EnableTestFeature flag in about:flags.', + activation_risks: 'Low risk of activation issues.', + measurement: 'Usage will be monitored via UMA counters.', + availability_expectation: 'Expected to be available in M125.', + adoption_expectation: 'High adoption expected from framework authors.', + adoption_plan: 'Blog post on web.dev and tutorials.', + + initial_public_proposal_url: 'https://discourse.example.com/t/123', + explainer_links: ['https://example.com/explainer.md'], + requires_embedder_support: false, + spec_link: 'https://example.com/spec.html', + api_spec: 'Yes', + prefixed: false, + interop_compat_risks: 'Some minor interop risks identified with Safari.', + all_platforms: true, + all_platforms_descr: 'Basic description.', + tag_review: 'TAG review requested, see link: https://example.com/tag/1', + non_oss_deps: 'No non-OSS dependencies.', + anticipated_spec_changes: 'Minor spec changes expected after TAG review.', + + security_risks: 'No major security risks identified.', + tags: ['security', 'privacy'], + tag_review_status: 'Pending', + tag_review_status_int: 1, + security_review_status: 'Pending', + security_review_status_int: 1, + privacy_review_status: 'Pending', + privacy_review_status_int: 1, + security_continuity_id: 9001, + security_launch_issue_id: 9002, + + ergonomics_risks: 'Potential ergonomics risk for developers if API is misused.', + wpt: true, + wpt_descr: 'WPTs are being written in parallel.', + webview_risks: 'Low risk for WebView.', + + devrel_emails: ['devrel1@example.com', 'devrel2@example.com'], + debuggability: 'Feature is debuggable via DevTools.', + doc_links: ['https://example.com/docs/test-feature'], + sample_links: ['https://example.com/samples/test-feature'], + + stages: [ + { + id: 201, + created: '2025-02-01T00:00:00Z', + feature_id: 1001, + stage_type: 150, + display_name: 'Prototype Stage', + intent_stage: 2, + intent_thread_url: 'https://example.com/intent-to-prototype', + announcement_url: 'https://example.com/prototype-announced', + origin_trial_id: 'OT_TestFeature_123', + experiment_goals: 'Gather feedback on API shape and usability.', + experiment_risks: 'Low risk, API is behind a flag.', + extensions: [], + origin_trial_feedback_url: 'https://example.com/ot-feedback', + ot_action_requested: true, + ot_activation_date: '2025-11-01T00:00:00Z', + ot_approval_buganizer_component: 12345, + ot_approval_buganizer_custom_field_id: 67890, + ot_approval_criteria_url: 'https://example.com/ot-criteria', + ot_approval_group_email: 'ot-approvers@example.com', + ot_chromium_trial_name: 'TestFeatureOriginTrial', + ot_description: 'An Origin Trial for the Test Feature.', + ot_display_name: 'Test Feature OT', + ot_documentation_url: 'https://example.com/ot-docs', + ot_emails: ['ot-admin@example.com', 'owner1@example.com'], + ot_feedback_submission_url: 'https://example.com/ot-submit', + ot_has_third_party_support: false, + ot_is_critical_trial: false, + ot_is_deprecation_trial: false, + ot_owner_email: 'ot-owner@example.com', + ot_require_approvals: true, + ot_setup_status: 1, + ot_request_note: 'Please approve this OT for M124.', + ot_stage_id: 201, + ot_use_counter_bucket_number: 5001, + experiment_extension_reason: 'Need more time for feedback.', + finch_url: 'https://example.com/finch-rollout', + rollout_details: 'Rolling out to 1% of users.', + rollout_milestone: 124, + rollout_platforms: ['Desktop', 'Android'], + enterprise_policies: ['TestFeatureEnabled', 'LegacyFeatureDisabled'], + pm_emails: ['pm@example.com'], + tl_emails: ['tl@example.com'], + ux_emails: ['ux@example.com'], + te_emails: ['te@example.com'], + desktop_first: 124, + android_first: 124, + ios_first: 125, + webview_first: 124, + desktop_last: 126, + android_last: 126, + ios_last: 127, + webview_last: 126, + } + ], + + resources: { + samples: ['https://example.com/sample1', 'https://example.com/sample2'], + docs: ['https://example.com/doc1', 'https://example.com/doc2'], + }, + comments: 'This is a test feature with all fields filled.', + + ff_views: 1, + safari_views: 2, + web_dev_views: 1, + + browsers: { + chrome: { + bug: 'https://bugs.example.com/chrome/123', + blink_components: ['Blink>WebComponents'], + devrel: ['devrel1@example.com'], + owners: ['owner1@example.com'], + origintrial: true, + intervention: false, + prefixed: false, + flag: true, + status: { + text: 'In development', + val: 1, + milestone_str: '124', + }, + desktop: 124, + android: 124, + webview: 124, + ios: 125, + }, + ff: { + view: { + text: 'Positive', + val: 1, + url: 'https://example.com/ff-position', + notes: 'Firefox is supportive of the goals.', + }, + }, + safari: { + view: { + text: 'Neutral', + val: 2, + url: 'https://example.com/safari-position', + notes: 'Safari is waiting for more feedback.', + }, + }, + webdev: { + view: { + text: 'Positive', + val: 1, + url: 'https://example.com/webdev-position', + notes: 'Web developers are excited.', + }, + }, + other: { + view: { + text: 'No signal', + val: 0, + url: '', + notes: 'No signals from other engines.', + }, + }, + }, + + standards: { + spec: 'https://example.com/spec.html', + maturity: { + text: 'Early Draft', + short_text: 'Draft', + val: 1, + }, + }, + + is_released: false, + is_enterprise_feature: true, + updated_display: 'Oct 20, 2025', + new_crbug_url: 'https://crbug.com/new/component=Blink>WebComponents', + }; + const shippingInfoUpcoming = { + closestShippingDate: UPCOMING_SHIPPING_DATE, + isUpcoming: true, + hasShipped: false, + }; + const shippingInfoShipped = { + closestShippingDate: SHIPPED_SHIPPING_DATE, + isUpcoming: false, + hasShipped: true, + }; + const shippingInfoNeither = { + closestShippingDate: '', + isUpcoming: false, + hasShipped: false, + }; + + it('should return null if feature is upcoming but verified', () => { + const feature: Feature = {...baseFeature, accurate_as_of: VERIFIED_DATE}; + const result = getFeatureOutdatedBanner( + feature, + shippingInfoUpcoming, + CURRENT_DATE_MS, + true + ); + assert.isNull(result); + }); + + it('should return upcoming banner if upcoming, outdated, and user can edit', () => { + const feature = {...baseFeature, accurate_as_of: OUTDATED_DATE}; + const result = getFeatureOutdatedBanner( + feature, + shippingInfoUpcoming, + CURRENT_DATE_MS, + true + ); + assert.equal(getBannerId(result), 'outdated-icon'); + }); + + it('should return upcoming banner if upcoming, outdated, and user cannot edit', () => { + const feature = {...baseFeature, accurate_as_of: OUTDATED_DATE}; + const result = getFeatureOutdatedBanner( + feature, + shippingInfoUpcoming, + CURRENT_DATE_MS, + false + ); + assert.equal(getBannerId(result), 'outdated-icon'); + }); + + it('should return null if feature is shipped but verified', () => { + const feature = {...baseFeature, accurate_as_of: VERIFIED_DATE}; + const result = getFeatureOutdatedBanner( + feature, + shippingInfoShipped, + CURRENT_DATE_MS, + true + ); + assert.isNull(result); + }); + + it('should return author banner if shipped, outdated (<9 weeks), and user can edit', () => { + const feature = {...baseFeature, accurate_as_of: OUTDATED_DATE}; + const result = getFeatureOutdatedBanner( + feature, + shippingInfoShipped, + CURRENT_DATE_MS, + true + ); + assert.equal(getBannerId(result), 'shipped-outdated-author'); + }); + + it('should return null if shipped, outdated (<9 weeks), and user cannot edit', () => { + const feature = {...baseFeature, accurate_as_of: OUTDATED_DATE}; + const result = getFeatureOutdatedBanner( + feature, + shippingInfoShipped, + CURRENT_DATE_MS, + false + ); + assert.isNull(result); + }); + + it('should return "all" banner if shipped, outdated (>9 weeks), and user can edit', () => { + const feature = {...baseFeature, accurate_as_of: VERY_OUTDATED_DATE}; + const result = getFeatureOutdatedBanner( + feature, + shippingInfoShipped, + CURRENT_DATE_MS, + true + ); + // Author banner still takes precedence + assert.equal(getBannerId(result), 'shipped-outdated-author'); + }); + + it('should return "all" banner if shipped, outdated (>9 weeks), and user cannot edit', () => { + const feature = {...baseFeature, accurate_as_of: VERY_OUTDATED_DATE}; + const result = getFeatureOutdatedBanner( + feature, + shippingInfoShipped, + CURRENT_DATE_MS, + false + ); + assert.equal(getBannerId(result), 'shipped-outdated-all'); + }); + + it('should return null if not upcoming and not shipped', () => { + const feature = {...baseFeature, accurate_as_of: OUTDATED_DATE}; + const result = getFeatureOutdatedBanner( + feature, + shippingInfoNeither, + CURRENT_DATE_MS, + true + ); + assert.isNull(result); + }); + + it('should return upcoming banner if accurate_as_of is missing', () => { + const feature = {...baseFeature, accurate_as_of: undefined}; + const result = getFeatureOutdatedBanner( + feature, + shippingInfoUpcoming, + CURRENT_DATE_MS, + true + ); + // isUpcomingFeatureOutdated returns false if accurate_as_of is missing + assert.isNotNull(result); + }); + + it('should return null if accurate_as_of is missing for shipped', () => { + const feature = {...baseFeature, accurate_as_of: undefined}; + const result = getFeatureOutdatedBanner( + feature, + shippingInfoShipped, + CURRENT_DATE_MS, + true + ); + // isShippedFeatureOutdated returns false if accurate_as_of is missing + assert.isNull(result); + }); + + it('should return null if closestShippingDate is missing', () => { + const feature = {...baseFeature, accurate_as_of: OUTDATED_DATE}; + const shippingInfo = { + closestShippingDate: '', + isUpcoming: false, + hasShipped: true, + }; + const result = getFeatureOutdatedBanner( + feature, + shippingInfo, + CURRENT_DATE_MS, + true + ); + // isShippedFeatureOutdated returns false if closestShippingDate is missing + assert.isNull(result); + }); + }); }); diff --git a/client-src/js-src/cs-client.js b/client-src/js-src/cs-client.js index 284c36e267f0..60bee373e298 100644 --- a/client-src/js-src/cs-client.js +++ b/client-src/js-src/cs-client.js @@ -119,7 +119,7 @@ /** * @typedef {Object} FeatureDictInnerBrowserStatus * @property {string} [text] - * @property {string} [val] + * @property {number} [val] * @property {string} [milestone_str] */ @@ -237,7 +237,7 @@ * @property {boolean} [prefixed] * @property {string} [interop_compat_risks] * @property {boolean} [all_platforms] - * @property {boolean} [all_platforms_descr] + * @property {string} [all_platforms_descr] * @property {string} [tag_review] * @property {string} [non_oss_deps] * @property {string} [anticipated_spec_changes] @@ -300,7 +300,7 @@ * @property {string | null} [latest_beta] - The latest beta release date (optional). * @property {string | null} [final_beta] * @property {string | null} [early_stable] (optional). - * @property {Feature} features + * @property {Object.} features */ /**