| // Copyright 2023 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include <cups/ipp.h> |
| |
| #include "base/functional/callback_helpers.h" |
| #include "base/test/gmock_callback_support.h" |
| #include "base/test/scoped_feature_list.h" |
| #include "base/test/values_test_util.h" |
| #include "chrome/browser/chromeos/printing/cups_wrapper.h" |
| #include "chrome/browser/content_settings/host_content_settings_map_factory.h" |
| #include "chrome/browser/extensions/api/printing/printing_test_utils.h" |
| #include "chrome/browser/profiles/profile.h" |
| #include "chrome/browser/ui/web_applications/test/isolated_web_app_test_utils.h" |
| #include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_url_info.h" |
| #include "chrome/browser/web_applications/isolated_web_apps/test/isolated_web_app_builder.h" |
| #include "chromeos/printing/printer_configuration.h" |
| #include "components/content_settings/core/browser/host_content_settings_map.h" |
| #include "components/content_settings/core/common/content_settings_types.h" |
| #include "content/public/test/browser_test.h" |
| #include "content/public/test/browser_test_utils.h" |
| #include "printing/backend/cups_ipp_constants.h" |
| #include "testing/gtest/include/gtest/gtest.h" |
| #include "third_party/blink/public/common/features_generated.h" |
| |
| #if BUILDFLAG(IS_CHROMEOS) |
| #include "base/test/mock_callback.h" |
| #include "chrome/browser/browser_process.h" |
| #include "chrome/browser/printing/print_job.h" |
| #include "chrome/browser/printing/print_job_manager.h" |
| #include "printing/print_settings.h" |
| #include "printing/printed_document.h" |
| #endif |
| |
| namespace printing { |
| |
| namespace { |
| |
| constexpr char kId[] = "id"; |
| constexpr char kName[] = "name"; |
| |
| constexpr std::string_view kPrintScriptWithJobStatePlaceholder = R"( |
| (async () => { |
| const pdf = `%PDF-1.0 |
| 1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 ` + |
| `obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj 3 0 ` + |
| `obj<</Type/Page/MediaBox[0 0 3 3]>>endobj |
| xref |
| 0 4 |
| 0000000000 65535 f |
| 0000000010 00000 n |
| 0000000053 00000 n |
| 0000000102 00000 n |
| trailer<</Size 4/Root 1 0 R>> |
| startxref |
| 149 |
| %EOF`; |
| |
| const pdfBlob = new Blob([pdf], {type: 'application/pdf'}); |
| const printers = await navigator.printing.getPrinters(); |
| |
| const printJob = await printers[0].submitPrintJob("Title", { |
| data: pdfBlob |
| }, { |
| mediaCol: { |
| mediaSize: { |
| xDimension: 21000, |
| yDimension: 29700, |
| } |
| }, |
| mediaSource: "tray-1", |
| printColorMode: "color", |
| multipleDocumentHandling: "separate-documents-collated-copies", |
| printerResolution: { |
| crossFeedDirectionResolution: 300, |
| feedDirectionResolution: 400, |
| units: "dots-per-inch", |
| }, |
| }); |
| const printJobComplete = new Promise((resolve, reject) => { |
| printJob.onjobstatechange = () => { |
| if (printJob.attributes().jobState === $1) { |
| resolve(); |
| return; |
| } |
| }; |
| }); |
| await printJobComplete; |
| })(); |
| )"; |
| |
| using testing::_; |
| using testing::AtMost; |
| |
| #if BUILDFLAG(IS_CHROMEOS) |
| using testing::AllOf; |
| using testing::Contains; |
| using testing::Eq; |
| using testing::Field; |
| using testing::Pair; |
| using testing::Pointee; |
| using testing::Property; |
| #endif |
| |
| class MockCupsWrapper : public chromeos::CupsWrapper { |
| public: |
| ~MockCupsWrapper() override = default; |
| |
| MOCK_METHOD(void, |
| QueryCupsPrintJobs, |
| (const std::vector<std::string>& printer_ids, |
| base::OnceCallback<void(std::unique_ptr<QueryResult>)> callback), |
| (override)); |
| MOCK_METHOD(void, |
| CancelJob, |
| (const std::string& printer_id, int job_id), |
| (override)); |
| MOCK_METHOD( |
| void, |
| QueryCupsPrinterStatus, |
| (const std::string& printer_id, |
| base::OnceCallback<void(std::unique_ptr<::printing::PrinterStatus>)> |
| callback), |
| (override)); |
| }; |
| |
| #if BUILDFLAG(IS_CHROMEOS) |
| auto ValidatePrintSettings() { |
| // These are synced with the `kPrintScriptWithJobStatePlaceholder` script. |
| return AllOf( |
| // copies: |
| Property(&PrintSettings::copies, Eq(1)), |
| // mediaCol: |
| Property(&PrintSettings::requested_media, |
| Field(&PrintSettings::RequestedMedia::size_microns, |
| Eq(gfx::Size(210000, 297000)))), |
| // mediaSource: |
| Property(&PrintSettings::advanced_settings, |
| Contains(Pair(Eq(printing::kIppMediaSource), |
| Property(&base::Value::GetIfString, |
| Pointee(Eq("tray-1")))))), |
| // printColorMode: |
| Property(&PrintSettings::color, Eq(mojom::ColorModel::kColorModeColor)), |
| // printQuality: |
| Property(&PrintSettings::quality, Eq(mojom::Quality::kUnknownQuality)), |
| Property(&PrintSettings::title, Eq(u"Title")), |
| // multipleDocumentHandling: |
| Property(&PrintSettings::collate, Eq(true)), |
| // printerResolution: |
| Property(&PrintSettings::dpi_size, Eq(gfx::Size(300, 400)))); |
| } |
| #endif |
| |
| } // namespace |
| |
| class WebPrintingBrowserTestBase |
| : public web_app::IsolatedWebAppBrowserTestHarness { |
| public: |
| void SetUpOnMainThread() override { |
| IsolatedWebAppBrowserTestHarness::SetUpOnMainThread(); |
| std::unique_ptr<web_app::ScopedBundledIsolatedWebApp> app = |
| web_app::IsolatedWebAppBuilder( |
| web_app::ManifestBuilder().AddPermissionsPolicyWildcard( |
| network::mojom::PermissionsPolicyFeature::kWebPrinting)) |
| .BuildBundle(); |
| app->TrustSigningKey(); |
| web_app::IsolatedWebAppUrlInfo url_info = app->InstallChecked(profile()); |
| app_frame_ = OpenApp(url_info.app_id()); |
| |
| chromeos::CupsWrapper::SetCupsWrapperFactoryForTesting( |
| base::BindRepeating([]() -> std::unique_ptr<chromeos::CupsWrapper> { |
| auto wrapper = std::make_unique<MockCupsWrapper>(); |
| EXPECT_CALL(*wrapper, QueryCupsPrinterStatus(_, _)) |
| .Times(AtMost(1)) |
| .WillOnce(base::test::RunOnceCallback<1>([] { |
| auto status = std::make_unique<PrinterStatus>(); |
| status->state = IPP_PSTATE_IDLE; |
| status->reasons.push_back( |
| {.reason = |
| PrinterStatus::PrinterReason::Reason::kUnknownReason, |
| .severity = |
| PrinterStatus::PrinterReason::Severity::kReport}); |
| status->reasons.push_back( |
| {.reason = |
| PrinterStatus::PrinterReason::Reason::kDeveloperLow, |
| .severity = |
| PrinterStatus::PrinterReason::Severity::kWarning}); |
| status->message = "Ready to Print!"; |
| return status; |
| }())); |
| return wrapper; |
| })); |
| |
| HostContentSettingsMapFactory::GetForProfile(profile()) |
| ->SetDefaultContentSetting(ContentSettingsType::WEB_PRINTING, |
| ContentSetting::CONTENT_SETTING_ALLOW); |
| } |
| |
| void TearDownOnMainThread() override { |
| app_frame_ = nullptr; |
| chromeos::CupsWrapper::SetCupsWrapperFactoryForTesting( |
| base::NullCallback()); |
| } |
| |
| protected: |
| content::RenderFrameHost* app_frame() { return app_frame_; } |
| |
| private: |
| base::test::ScopedFeatureList feature_list_{blink::features::kWebPrinting}; |
| |
| raw_ptr<content::RenderFrameHost> app_frame_ = nullptr; |
| }; |
| |
| #if BUILDFLAG(IS_CHROMEOS) |
| class WebPrintingBrowserTest : public WebPrintingBrowserTestBase { |
| public: |
| void PreRunTestOnMainThread() override { |
| WebPrintingBrowserTestBase::PreRunTestOnMainThread(); |
| helper_->Init(profile()); |
| } |
| |
| void TearDownOnMainThread() override { |
| helper_.reset(); |
| WebPrintingBrowserTestBase::TearDownOnMainThread(); |
| } |
| |
| void SetUpInProcessBrowserTestFixture() override { |
| WebPrintingBrowserTestBase::SetUpInProcessBrowserTestFixture(); |
| helper_ = std::make_unique<extensions::PrintingTestHelper>(); |
| } |
| |
| protected: |
| void AddPrinterWithSemanticCaps( |
| const std::string& printer_id, |
| const std::string& printer_display_name, |
| std::unique_ptr<printing::PrinterSemanticCapsAndDefaults> caps) { |
| helper_->AddAvailablePrinter(printer_id, printer_display_name, |
| std::move(caps)); |
| } |
| |
| extensions::PrintingBackendInfrastructureHelper& printing_infra_helper() { |
| return helper_->printing_infra_helper(); |
| } |
| |
| private: |
| std::unique_ptr<extensions::PrintingTestHelper> helper_; |
| }; |
| #endif // BUILDFLAG(IS_CHROMEOS) |
| |
| IN_PROC_BROWSER_TEST_F(WebPrintingBrowserTest, GetPrinters) { |
| #if BUILDFLAG(IS_CHROMEOS) |
| AddPrinterWithSemanticCaps(kId, kName, |
| extensions::ConstructPrinterCapabilities()); |
| #endif |
| |
| constexpr std::string_view kGetPrintersScript = R"( |
| (async () => { |
| try { |
| const printers = await navigator.printing.getPrinters(); |
| if (printers.length !== 1 || |
| printers[0].cachedAttributes().printerName !== $1) { |
| return false; |
| } |
| return true; |
| } catch (err) { |
| console.log(err); |
| return false; |
| } |
| })(); |
| )"; |
| |
| ASSERT_TRUE(EvalJs(app_frame(), content::JsReplace(kGetPrintersScript, kName)) |
| .ExtractBool()); |
| } |
| |
| IN_PROC_BROWSER_TEST_F(WebPrintingBrowserTest, FetchAttributes) { |
| #if BUILDFLAG(IS_CHROMEOS) |
| AddPrinterWithSemanticCaps(kId, kName, |
| extensions::ConstructPrinterCapabilities()); |
| #endif |
| |
| // Keep in sync with extensions::ConstructPrinterCapabilities(). |
| constexpr std::string_view kExpectedAttributes = R"({ |
| "copiesDefault": 1, |
| "copiesSupported": { |
| "from": 1, |
| "to": 2 |
| }, |
| "documentFormatDefault": "application/pdf", |
| "documentFormatSupported": [ "application/pdf" ], |
| "mediaColDefault": { |
| "mediaSize": { |
| "xDimension": 21000, |
| "yDimension": 29700, |
| }, |
| "mediaSizeName": "iso_a4_210x297mm", |
| }, |
| "mediaColDatabase": [{ |
| "mediaSize": { |
| "xDimension": 21000, |
| "yDimension": 29700, |
| }, |
| "mediaSizeName": "iso_a4_210x297mm", |
| }, { |
| "mediaSize": { |
| "xDimension": 21590, |
| "yDimension": 27940, |
| }, |
| "mediaSizeName": "na_letter_8.5x11in", |
| }, { |
| "mediaSize": { |
| "xDimension": 20000, |
| "yDimension": { |
| "from": 25000, |
| "to": 30000, |
| }, |
| }, |
| "mediaSizeName": "om_200000x250000um_200x250mm", |
| }], |
| "mediaSourceDefault": "auto", |
| "mediaSourceSupported": [ "auto", "tray-1" ], |
| "multipleDocumentHandlingDefault": "separate-documents-uncollated-copies", |
| "multipleDocumentHandlingSupported": [ |
| "separate-documents-uncollated-copies", |
| "separate-documents-collated-copies" |
| ], |
| "orientationRequestedDefault": "portrait", |
| "orientationRequestedSupported": [ "portrait", "landscape" ], |
| "printerResolutionDefault": { |
| "crossFeedDirectionResolution": 300, |
| "feedDirectionResolution": 400, |
| "units": "dots-per-inch", |
| }, |
| "printerResolutionSupported": [{ |
| "crossFeedDirectionResolution": 300, |
| "feedDirectionResolution": 400, |
| "units": "dots-per-inch", |
| }], |
| "printColorModeDefault": "monochrome", |
| "printColorModeSupported": [ "monochrome", "color" ], |
| "printQualityDefault": "draft", |
| "printQualitySupported": [ "draft", "normal" ], |
| "printerName": "name", |
| "printerState": "idle", |
| "printerStateMessage": "Ready to Print!", |
| "printerStateReasons": [ "other", "developer-low" ], |
| "sidesDefault": "one-sided", |
| "sidesSupported": [ "one-sided" ] |
| })"; |
| |
| constexpr std::string_view kFetchAttributesScript = R"( |
| (async () => { |
| const printers = await navigator.printing.getPrinters(); |
| return await printers[0].fetchAttributes(); |
| })(); |
| )"; |
| |
| auto eval_result = EvalJs(app_frame(), kFetchAttributesScript); |
| ASSERT_THAT(eval_result, content::EvalJsResult::IsOk()); |
| |
| EXPECT_THAT(eval_result.ExtractDict(), |
| base::test::DictionaryHasValues( |
| base::test::ParseJsonDict(kExpectedAttributes))); |
| } |
| |
| IN_PROC_BROWSER_TEST_F(WebPrintingBrowserTest, Print) { |
| #if BUILDFLAG(IS_CHROMEOS) |
| // Set up a matcher to validate correctness of `PrintSettings`. |
| base::MockRepeatingCallback<void(PrintJob*, PrintedDocument*, int)> |
| doc_done_cb; |
| EXPECT_CALL( |
| doc_done_cb, |
| Run(_, Property(&PrintedDocument::settings, ValidatePrintSettings()), _)); |
| auto subscription = |
| g_browser_process->print_job_manager()->AddDocDoneCallback( |
| doc_done_cb.Get()); |
| |
| AddPrinterWithSemanticCaps(kId, kName, |
| extensions::ConstructPrinterCapabilities()); |
| #endif |
| |
| const auto script = content::JsReplace(kPrintScriptWithJobStatePlaceholder, |
| /*job_state=*/"completed"); |
| ASSERT_THAT(EvalJs(app_frame(), script), content::EvalJsResult::IsOk()); |
| } |
| |
| IN_PROC_BROWSER_TEST_F(WebPrintingBrowserTest, PrintFailure) { |
| #if BUILDFLAG(IS_CHROMEOS) |
| AddPrinterWithSemanticCaps(kId, kName, |
| extensions::ConstructPrinterCapabilities()); |
| #endif |
| printing_infra_helper() |
| .test_printing_context_factory() |
| .SetFailedErrorOnNewDocument(/*cause_errors=*/true); |
| |
| const auto script = content::JsReplace(kPrintScriptWithJobStatePlaceholder, |
| /*job_state=*/"aborted"); |
| ASSERT_THAT(EvalJs(app_frame(), script), content::EvalJsResult::IsOk()); |
| } |
| |
| // Validate that call to `navigator.printing.getPrinters()` fails when content |
| // setting is set to BLOCK. |
| IN_PROC_BROWSER_TEST_F(WebPrintingBrowserTest, |
| GetPrintersUserPermissionDenied) { |
| HostContentSettingsMapFactory::GetForProfile(profile()) |
| ->SetDefaultContentSetting(ContentSettingsType::WEB_PRINTING, |
| ContentSetting::CONTENT_SETTING_BLOCK); |
| |
| constexpr std::string_view kGetPrintersScript = R"( |
| (async () => { |
| const printers = await navigator.printing.getPrinters(); |
| })(); |
| )"; |
| |
| ASSERT_THAT( |
| EvalJs(app_frame(), kGetPrintersScript), |
| content::EvalJsResult::ErrorIs(testing::HasSubstr("User denied access"))); |
| } |
| |
| // Validate that further calls to printer's methods fail when content setting |
| // gets switched to BLOCK after a successful call to |
| // `navigator.printing.getPrinters()`. |
| IN_PROC_BROWSER_TEST_F(WebPrintingBrowserTest, |
| FetchAndPrintUserPermissionDenied) { |
| #if BUILDFLAG(IS_CHROMEOS) |
| AddPrinterWithSemanticCaps(kId, kName, |
| extensions::ConstructPrinterCapabilities()); |
| #endif |
| |
| // Call `navigator.printing.getPrinters()` while the permission is active. |
| constexpr std::string_view kGetPrintersScript = R"( |
| (async () => { |
| const printers = await navigator.printing.getPrinters(); |
| printer = printers[0]; |
| })(); |
| )"; |
| ASSERT_THAT(EvalJs(app_frame(), kGetPrintersScript), |
| content::EvalJsResult::IsOk()); |
| |
| HostContentSettingsMapFactory::GetForProfile(profile()) |
| ->SetDefaultContentSetting(ContentSettingsType::WEB_PRINTING, |
| ContentSetting::CONTENT_SETTING_BLOCK); |
| |
| // Ensure that `printer.fetchAttributes()` reports access denied. |
| constexpr std::string_view kFetchAttributesScript = R"( |
| (async () => { |
| await printer.fetchAttributes(); |
| })(); |
| )"; |
| ASSERT_THAT( |
| EvalJs(app_frame(), kFetchAttributesScript), |
| content::EvalJsResult::ErrorIs(testing::HasSubstr("User denied access"))); |
| |
| // Ensure that `printer.submitPrintJob()` reports access denied. |
| constexpr std::string_view kPrintJobScript = R"( |
| (async () => { |
| const pdf = `%PDF-1.0 |
| 1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 ` + |
| `obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj 3 0 ` + |
| `obj<</Type/Page/MediaBox[0 0 3 3]>>endobj |
| xref |
| 0 4 |
| 0000000000 65535 f |
| 0000000010 00000 n |
| 0000000053 00000 n |
| 0000000102 00000 n |
| trailer<</Size 4/Root 1 0 R>> |
| startxref |
| 149 |
| %EOF`; |
| const pdfBlob = new Blob([pdf], {type: 'application/pdf'}); |
| const printJob = await printer.submitPrintJob("Fail", { data: pdfBlob }, |
| {}); |
| })(); |
| )"; |
| ASSERT_THAT( |
| EvalJs(app_frame(), kPrintJobScript), |
| content::EvalJsResult::ErrorIs(testing::HasSubstr("User denied access"))); |
| } |
| |
| IN_PROC_BROWSER_TEST_F(WebPrintingBrowserTest, CancelImmediately) { |
| #if BUILDFLAG(IS_CHROMEOS) |
| AddPrinterWithSemanticCaps(kId, kName, |
| extensions::ConstructPrinterCapabilities()); |
| #endif |
| constexpr std::string_view kCancelEarlyScript = R"( |
| (async () => { |
| const pdf = `%PDF-1.0 |
| 1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 ` + |
| `obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj 3 0 ` + |
| `obj<</Type/Page/MediaBox[0 0 3 3]>>endobj |
| xref |
| 0 4 |
| 0000000000 65535 f |
| 0000000010 00000 n |
| 0000000053 00000 n |
| 0000000102 00000 n |
| trailer<</Size 4/Root 1 0 R>> |
| startxref |
| 149 |
| %EOF`; |
| |
| const pdfBlob = new Blob([pdf], {type: 'application/pdf'}); |
| const printers = await navigator.printing.getPrinters(); |
| |
| const printJob = await printers[0].submitPrintJob("Title", |
| { data: pdfBlob }, {}); |
| let phase = 0; |
| const printJobCanceled = new Promise((resolve, reject) => { |
| printJob.onjobstatechange = () => { |
| const state = printJob.attributes().jobState; |
| if (state === 'pending') { |
| if (phase !== 0) { |
| throw new Error('Wrong sequence: kPending should come first.'); |
| return; |
| } |
| phase += 1; |
| } else if (state === 'canceled') { |
| if (phase !== 1) { |
| throw new Error('Wrong sequence: kCanceled should come second.'); |
| return; |
| } |
| resolve(); |
| } |
| }; |
| }); |
| printJob.cancel(); |
| await printJobCanceled; |
| })(); |
| )"; |
| ASSERT_THAT(EvalJs(app_frame(), kCancelEarlyScript), |
| content::EvalJsResult::IsOk()); |
| } |
| |
| IN_PROC_BROWSER_TEST_F(WebPrintingBrowserTest, CancelHalfway) { |
| #if BUILDFLAG(IS_CHROMEOS) |
| AddPrinterWithSemanticCaps(kId, kName, |
| extensions::ConstructPrinterCapabilities()); |
| #endif |
| constexpr std::string_view kCancelHalfwayScript = R"( |
| (async () => { |
| const pdf = `%PDF-1.0 |
| 1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 ` + |
| `obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj 3 0 ` + |
| `obj<</Type/Page/MediaBox[0 0 3 3]>>endobj |
| xref |
| 0 4 |
| 0000000000 65535 f |
| 0000000010 00000 n |
| 0000000053 00000 n |
| 0000000102 00000 n |
| trailer<</Size 4/Root 1 0 R>> |
| startxref |
| 149 |
| %EOF`; |
| |
| const pdfBlob = new Blob([pdf], {type: 'application/pdf'}); |
| const printers = await navigator.printing.getPrinters(); |
| |
| const printJob = await printers[0].submitPrintJob("Title", |
| { data: pdfBlob }, {}); |
| let phase = 0; |
| const printJobProcessingThenCanceled = new Promise((resolve, reject) => { |
| printJob.onjobstatechange = () => { |
| const state = printJob.attributes().jobState; |
| if (state === 'processing') { |
| if (phase !== 0) { |
| throw new Error('Wrong sequence: kProcessing should come first.'); |
| return; |
| } |
| phase += 1; |
| printJob.cancel(); |
| } else if (state === 'canceled') { |
| if (phase !== 1) { |
| throw new Error('Wrong sequence: kCanceled should come second.'); |
| return; |
| } |
| resolve(); |
| } |
| }; |
| }); |
| await printJobProcessingThenCanceled; |
| })(); |
| )"; |
| ASSERT_THAT(EvalJs(app_frame(), kCancelHalfwayScript), |
| content::EvalJsResult::IsOk()); |
| } |
| |
| } // namespace printing |