Repository: microsoft/qsharp
Pull request #2126 (2025-01-25 18:53:33)

Long gate in ASCII art circuits - lengthen column width when necessary
See issue https://github.com/microsoft/qsharp/issues/1476.

I tried to keep the logic as close as possible to the original, because I wanted to keep it easy to review it in relation to the old logic.

The main change here is that instead of having the `ObjectsByColumn` of every Row be a `FxHashMap<usize, String>` which contains the 7-width-wide strings, it's now a `FxHashMap<usize, CircuitObject>`, where `CircuitObject` is an enum that supports every circuit object except for than blanks and wires (because those are inserted later on, on-the-go).

Using this enum allows us to calculate the required `ColumnWidthsByColumn` (a `FxHashMap<usize, usize>`) right before calling `row.fmt` - so we know what width the `CircuitObjects` need to be when converted into strings.

In the example from the ticket, this means that this circuit:
```
use q3 = Qubit();

H(q3);
Rx(1.0, q3);
H(q3);
Rx(1.0, q3);
H(q3);
Rx(1.0, q3);
```

Which used to give:
```
q_0    ── H ─────────── ● ──── M ────────────────
                        │      ╘═════════════════
q_1    ── H ──── X ──── X ───────────────────────
q_2     rx(1.0000)  rx(1.0000) ────────────────────────────
q_3    ── H ── rx(1.0000) ── H ── rx(1.0000) ── H ── rx(1.0000)
```

Now gives
```
q_0    ────── H ─────────────────────── ● ──────── M ────────────────────────────
                                        │          ╘═════════════════════════════
q_1    ────── H ──────────── X ──────── X ───────────────────────────────────────
q_2    ─ rx(1.0000) ─── rx(1.0000) ──────────────────────────────────────────────
q_3    ────── H ─────── rx(1.0000) ──── H ─── rx(1.0000) ──── H ─── rx(1.0000) ──
```


Apart from this desired change for long gates, the only notable difference is that, where ket-zero (`|0〉`) used to show as a width-5 circuit object (`─ |0〉 ─`, it now shows as a width-7 circuit object (`── |0〉 ──`) - you can see the difference in every test that used it.


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 10ec305c..6a89a2bc 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1379,6 +1379,7 @@ dependencies = [
  "qsc_hir",
  "qsc_parse",
  "qsc_passes",
+ "rustc-hash",
  "serde",
  "serde_json",
  "thiserror",
diff --git a/compiler/qsc_circuit/src/circuit.rs b/compiler/qsc_circuit/src/circuit.rs
index e9493b8b..508845f3 100644
--- a/compiler/qsc_circuit/src/circuit.rs
+++ b/compiler/qsc_circuit/src/circuit.rs
@@ -95,7 +95,8 @@ impl Config {
     pub const DEFAULT_MAX_OPERATIONS: usize = 10001;
 }
 
-type ObjectsByColumn = FxHashMap<usize, String>;
+type ObjectsByColumn = FxHashMap<usize, CircuitObject>;
+type ColumnWidthsByColumn = FxHashMap<usize, usize>;
 
 struct Row {
     wire: Wire,
@@ -108,16 +109,18 @@ enum Wire {
     Classical { start_column: Option<usize> },
 }
 
+enum CircuitObject {
+    WireCross,
+    WireStart,
+    DashedCross,
+    Vertical,
+    VerticalDashed,
+    Object(String),
+}
+
 impl Row {
     fn add_object(&mut self, column: usize, object: &str) {
-        match &mut self.wire {
-            Wire::Qubit { .. } => {
-                self.add(column, fmt_on_qubit_wire(object));
-            }
-            Wire::Classical { .. } => {
-                self.add(column, fmt_on_classical_wire(object));
-            }
-        };
+        self.add(column, CircuitObject::Object(object.to_string()));
     }
 
     fn add_gate(&mut self, column: usize, gate: &str, args: Option<&str>, is_adjoint: bool) {
@@ -129,18 +132,19 @@ impl Row {
         if let Some(args) = args {
             let _ = write!(&mut gate_label, "({args})");
         }
-        self.add_object(column, &gate_label);
+
+        self.add_object(column, gate_label.as_str());
     }
 
     fn add_vertical(&mut self, column: usize) {
         if !self.objects.contains_key(&column) {
             match self.wire {
-                Wire::Qubit { .. } => self.add(column, QUBIT_WIRE_CROSS),
+                Wire::Qubit { .. } => self.add(column, CircuitObject::WireCross),
                 Wire::Classical { start_column } => {
                     if start_column.is_some() {
-                        self.add(column, CLASSICAL_WIRE_CROSS);
+                        self.add(column, CircuitObject::WireCross);
                     } else {
-                        self.add(column, VERTICAL);
+                        self.add(column, CircuitObject::Vertical);
                     }
                 }
             }
@@ -150,12 +154,12 @@ impl Row {
     fn add_dashed_vertical(&mut self, column: usize) {
         if !self.objects.contains_key(&column) {
             match self.wire {
-                Wire::Qubit { .. } => self.add(column, QUBIT_WIRE_DASHED_CROSS),
+                Wire::Qubit { .. } => self.add(column, CircuitObject::DashedCross),
                 Wire::Classical { start_column } => {
                     if start_column.is_some() {
-                        self.add(column, CLASSICAL_WIRE_DASHED_CROSS);
+                        self.add(column, CircuitObject::DashedCross);
                     } else {
-                        self.add(column, VERTICAL_DASHED);
+                        self.add(column, CircuitObject::VerticalDashed);
                     }
                 }
             }
@@ -163,18 +167,23 @@ impl Row {
     }
 
     fn start_classical(&mut self, column: usize) {
-        self.add(column, CLASSICAL_WIRE_START);
+        self.add(column, CircuitObject::WireStart);
         if let Wire::Classical { start_column } = &mut self.wire {
             start_column.replace(column);
         }
     }
 
-    fn add(&mut self, column: usize, v: impl Into<String>) {
-        self.objects.insert(column, v.into());
+    fn add(&mut self, column: usize, circuit_object: CircuitObject) {
+        self.objects.insert(column, circuit_object);
         self.next_column = column + 1;
     }
 
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>, end_column: usize) -> std::fmt::Result {
+    fn fmt(
+        &self,
+        f: &mut std::fmt::Formatter<'_>,
+        column_widths: &ColumnWidthsByColumn,
+        end_column: usize,
+    ) -> std::fmt::Result {
         // Temporary string so we can trim whitespace at the end
         let mut s = String::new();
         match &self.wire {
@@ -182,22 +191,24 @@ impl Row {
                 s.write_str(&fmt_qubit_label(*label))?;
                 for column in 1..end_column {
                     let val = self.objects.get(&column);
+                    let column_width = *column_widths.get(&column).unwrap_or(&MIN_COLUMN_WIDTH);
                     if let Some(v) = val {
-                        s.write_str(v)?;
+                        s.write_str(&fmt_qubit_circuit_object(v, column_width))?;
                     } else {
-                        s.write_str(QUBIT_WIRE)?;
+                        s.write_str(&get_qubit_wire(column_width))?;
                     }
                 }
             }
             Wire::Classical { start_column } => {
                 for column in 0..end_column {
                     let val = self.objects.get(&column);
+                    let column_width = *column_widths.get(&column).unwrap_or(&MIN_COLUMN_WIDTH);
                     if let Some(v) = val {
-                        s.write_str(v)?;
+                        s.write_str(&fmt_classical_circuit_object(v, column_width))?;
                     } else if start_column.map_or(false, |s| column > s) {
-                        s.write_str(CLASSICAL_WIRE)?;
+                        s.write_str(&get_classical_wire(column_width))?;
                     } else {
-                        s.write_str(BLANK)?;
+                        s.write_str(&get_blank(column_width))?;
                     }
                 }
             }
@@ -207,33 +218,103 @@ impl Row {
     }
 }
 
-const COLUMN_WIDTH: usize = 7;
-const QUBIT_WIRE: &str = "───────";
-const CLASSICAL_WIRE: &str = "═══════";
-const QUBIT_WIRE_CROSS: &str = "───┼───";
-const CLASSICAL_WIRE_CROSS: &str = "═══╪═══";
-const CLASSICAL_WIRE_START: &str = "   ╘═══";
-const QUBIT_WIRE_DASHED_CROSS: &str = "───┆───";
-const CLASSICAL_WIRE_DASHED_CROSS: &str = "═══┆═══";
-const VERTICAL_DASHED: &str = "   ┆   ";
-const VERTICAL: &str = "   │   ";
-const BLANK: &str = "       ";
+const MIN_COLUMN_WIDTH: usize = 7;
+
+/// "───────"
+fn get_qubit_wire(column_width: usize) -> String {
+    "─".repeat(column_width)
+}
+
+/// "═══════"
+fn get_classical_wire(column_width: usize) -> String {
+    "═".repeat(column_width)
+}
+
+/// "───┼───"
+fn get_qubit_wire_cross(column_width: usize) -> String {
+    let half_width = "─".repeat(column_width / 2);
+    format!("{}┼{}", half_width, half_width)
+}
+
+/// "═══╪═══"
+fn get_classical_wire_cross(column_width: usize) -> String {
+    let half_width = "═".repeat(column_width / 2);
+    format!("{}╪{}", half_width, half_width)
+}
+
+/// "   ╘═══"
+fn get_classical_wire_start(column_width: usize) -> String {
+    let first_half_width = " ".repeat(column_width / 2);
+    let second_half_width = "═".repeat(column_width / 2);
+    format!("{}╘{}", first_half_width, second_half_width)
+}
+
+/// "───┆───"
+fn get_qubit_wire_dashed_cross(column_width: usize) -> String {
+    let half_width = "─".repeat(column_width / 2);
+    format!("{}┆{}", half_width, half_width)
+}
+
+/// "═══┆═══"
+fn get_classical_wire_dashed_cross(column_width: usize) -> String {
+    let half_width = "═".repeat(column_width / 2);
+    format!("{}┆{}", half_width, half_width)
+}
+
+/// "   │   "
+fn get_vertical(column_width: usize) -> String {
+    let half_width = " ".repeat(column_width / 2);
+    format!("{}│{}", half_width, half_width)
+}
+
+/// "   ┆   "
+fn get_vertical_dashed(column_width: usize) -> String {
+    let half_width = " ".repeat(column_width / 2);
+    format!("{}┆{}", half_width, half_width)
+}
+
+/// "       "
+fn get_blank(column_width: usize) -> String {
+    " ".repeat(column_width)
+}
 
 /// "q_0  "
 #[allow(clippy::doc_markdown)]
 fn fmt_qubit_label(id: usize) -> String {
-    let rest = COLUMN_WIDTH - 2;
+    let rest = MIN_COLUMN_WIDTH - 2;
     format!("q_{id: <rest$}")
 }
 
 /// "── A ──"
-fn fmt_on_qubit_wire(obj: &str) -> String {
-    format!("{:─^COLUMN_WIDTH$}", format!(" {obj} "))
+fn fmt_on_qubit_wire(obj: &str, column_width: usize) -> String {
+    format!("{:─^column_width$}", format!(" {obj} "))
 }
 
 /// "══ A ══"
-fn fmt_on_classical_wire(obj: &str) -> String {
-    format!("{:═^COLUMN_WIDTH$}", format!(" {obj} "))
+fn fmt_on_classical_wire(obj: &str, column_width: usize) -> String {
+    format!("{:═^column_width$}", format!(" {obj} "))
+}
+
+fn fmt_classical_circuit_object(circuit_object: &CircuitObject, column_width: usize) -> String {
+    match circuit_object {
+        CircuitObject::WireCross => get_classical_wire_cross(column_width),
+        CircuitObject::WireStart => get_classical_wire_start(column_width),
+        CircuitObject::DashedCross => get_classical_wire_dashed_cross(column_width),
+        CircuitObject::Vertical => get_vertical(column_width),
+        CircuitObject::VerticalDashed => get_vertical_dashed(column_width),
+        CircuitObject::Object(label) => fmt_on_classical_wire(label.as_str(), column_width),
+    }
+}
+
+fn fmt_qubit_circuit_object(circuit_object: &CircuitObject, column_width: usize) -> String {
+    match circuit_object {
+        CircuitObject::WireCross => get_qubit_wire_cross(column_width),
+        CircuitObject::WireStart => get_blank(column_width), // This should never happen
+        CircuitObject::DashedCross => get_qubit_wire_dashed_cross(column_width),
+        CircuitObject::Vertical => get_vertical(column_width),
+        CircuitObject::VerticalDashed => get_vertical_dashed(column_width),
+        CircuitObject::Object(label) => fmt_on_qubit_wire(label.as_str(), column_width),
+    }
 }
 
 impl Display for Circuit {
@@ -289,6 +370,7 @@ impl Display for Circuit {
             let mut all_rows = targets.clone();
             all_rows.extend(controls.iter());
             all_rows.sort_unstable();
+
             // We'll need to know the entire range of rows for this operation so we can
             // figure out the starting column and also so we can draw any
             // vertical lines that cross wires.
@@ -298,12 +380,11 @@ impl Display for Circuit {
 
             // The starting column - the first available column in all
             // the rows that this operation spans.
-            let mut column = 1;
-            for row in &rows[begin..end] {
-                if row.next_column > column {
-                    column = row.next_column;
-                }
-            }
+            let column = rows[begin..end]
+                .iter()
+                .map(|r| r.next_column)
+                .max()
+                .unwrap_or(1);
 
             // Add the operation to the diagram
             for i in targets {
@@ -348,9 +429,28 @@ impl Display for Circuit {
             .max_by_key(|r| r.next_column)
             .map_or(1, |r| r.next_column);
 
+        // To be able to fit long-named operations, we calculate the required width for each column,
+        // based on the maximum length needed for gates, where a gate X is printed as "- X -".
+        let column_widths = (0..end_column)
+            .map(|column| {
+                (
+                    column,
+                    rows.iter()
+                        .filter_map(|row| row.objects.get(&column))
+                        .filter_map(|object| match object {
+                            CircuitObject::Object(string) => Some((string.len() + 4) | 1), // Column lengths need to be odd numbers
+                            _ => None,
+                        })
+                        .chain(std::iter::once(MIN_COLUMN_WIDTH))
+                        .max()
+                        .unwrap(),
+                )
+            })
+            .collect::<ColumnWidthsByColumn>();
+
         // Draw the diagram
         for row in rows {
-            row.fmt(f, end_column)?;
+            row.fmt(f, &column_widths, end_column)?;
         }
 
         Ok(())
diff --git a/compiler/qsc_doc_gen/src/generate_docs.rs b/compiler/qsc_doc_gen/src/generate_docs.rs
index f3d7ce45..6e48a48e 100644
--- a/compiler/qsc_doc_gen/src/generate_docs.rs
+++ b/compiler/qsc_doc_gen/src/generate_docs.rs
@@ -522,7 +522,7 @@ fn generate_index_file(files: &mut FilesWithMetadata, ns: &Rc<str>, items: &mut
                 "| [{}](xref:Qdk.{}) | {} |",
                 item.name,
                 item.fully_qualified_name(),
-                item.summary
+                item.summary.replace('|', "\\|")
             )
         })
         .collect::<Vec<_>>()
@@ -698,8 +698,7 @@ fn get_metadata(
 
     let summary = parse_doc_for_summary(&item.doc)
         .replace("\r\n", " ")
-        .replace('\n', " ")
-        .replace('|', "\\|");
+        .replace('\n', " ");
 
     Some(Metadata {
         uid: format!("Qdk.{ns}.{name}"),
diff --git a/compiler/qsc_frontend/src/lower.rs b/compiler/qsc_frontend/src/lower.rs
index fb56d42d..fcb8a421 100644
--- a/compiler/qsc_frontend/src/lower.rs
+++ b/compiler/qsc_frontend/src/lower.rs
@@ -443,6 +443,18 @@ impl With<'_> {
                     None
                 }
             },
+            Ok(hir::Attr::Test) => {
+                // verify that no args are passed to the attribute
+                match &*attr.arg.kind {
+                    ast::ExprKind::Tuple(args) if args.is_empty() => {}
+                    _ => {
+                        self.lowerer
+                            .errors
+                            .push(Error::InvalidAttrArgs("()".to_string(), attr.arg.span));
+                    }
+                }
+                Some(hir::Attr::Test)
+            }
             Err(()) => {
                 self.lowerer.errors.push(Error::UnknownAttr(
                     attr.name.name.to_string(),
diff --git a/compiler/qsc_hir/src/hir.rs b/compiler/qsc_hir/src/hir.rs
index ade89e54..45d9732a 100644
--- a/compiler/qsc_hir/src/hir.rs
+++ b/compiler/qsc_hir/src/hir.rs
@@ -279,6 +279,58 @@ impl Display for Package {
     }
 }
 
+/// The name of a test callable, including its parent namespace.
+pub type TestCallableName = String;
+
+impl Package {
+    /// Returns a collection of the fully qualified names of any callables annotated with `@Test()`
+    pub fn get_test_callables(&self) -> Vec<(TestCallableName, Span)> {
+        let items_with_test_attribute = self
+            .items
+            .iter()
+            .filter(|(_, item)| item.attrs.iter().any(|attr| *attr == Attr::Test));
+
+        let callables = items_with_test_attribute
+            .filter(|(_, item)| matches!(item.kind, ItemKind::Callable(_)));
+
+        let callable_names = callables
+            .filter_map(|(_, item)| -> Option<_> {
+                if let ItemKind::Callable(callable) = &item.kind {
+                    if !callable.generics.is_empty()
+                        || callable.input.kind != PatKind::Tuple(vec![])
+                    {
+                        return None;
+                    }
+
+                    // this is indeed a test callable, so let's grab its parent name
+                    let (name, span) = match item.parent {
+                        None => Default::default(),
+                        Some(parent_id) => {
+                            let parent_item = self
+                                .items
+                                .get(parent_id)
+                                .expect("Parent item did not exist in package");
+                            let name = if let ItemKind::Namespace(ns, _) = &parent_item.kind {
+                                format!("{}.{}", ns.name(), callable.name.name)
+                            } else {
+                                callable.name.name.to_string()
+                            };
+                            let span = callable.name.span;
+                            (name, span)
+                        }
+                    };
+
+                    Some((name, span))
+                } else {
+                    None
+                }
+            })
+            .collect::<Vec<_>>();
+
+        callable_names
+    }
+}
+
 /// An item.
 #[derive(Clone, Debug, PartialEq)]
 pub struct Item {
@@ -1365,6 +1417,8 @@ pub enum Attr {
     /// Indicates that an intrinsic callable is a reset. This means that the operation will be marked as
     /// "irreversible" in the generated QIR.
     Reset,
+    /// Indicates that a callable is a test case.
+    Test,
 }
 
 impl Attr {
@@ -1382,6 +1436,7 @@ The `not` operator is also supported to negate the attribute, e.g. `not Adaptive
             Attr::SimulatableIntrinsic => "Indicates that an item should be treated as an intrinsic callable for QIR code generation and any implementation should only be used during simulation.",
             Attr::Measurement => "Indicates that an intrinsic callable is a measurement. This means that the operation will be marked as \"irreversible\" in the generated QIR, and output Result types will be moved to the arguments.",
             Attr::Reset => "Indicates that an intrinsic callable is a reset. This means that the operation will be marked as \"irreversible\" in the generated QIR.",
+            Attr::Test =>  "Indicates that a callable is a test case.",
         }
     }
 }
@@ -1397,6 +1452,7 @@ impl FromStr for Attr {
             "SimulatableIntrinsic" => Ok(Self::SimulatableIntrinsic),
             "Measurement" => Ok(Self::Measurement),
             "Reset" => Ok(Self::Reset),
+            "Test" => Ok(Self::Test),
             _ => Err(()),
         }
     }
diff --git a/compiler/qsc_linter/Cargo.toml b/compiler/qsc_linter/Cargo.toml
index 833936db..f7d7933f 100644
--- a/compiler/qsc_linter/Cargo.toml
+++ b/compiler/qsc_linter/Cargo.toml
@@ -15,6 +15,7 @@ qsc_hir = { path = "../qsc_hir" }
 qsc_data_structures = { path = "../qsc_data_structures" }
 qsc_frontend = { path = "../qsc_frontend" }
 qsc_doc_gen = { path = "../qsc_doc_gen" }
+rustc-hash = { workspace = true }
 serde = { workspace = true }
 thiserror = { workspace = true }
 
diff --git a/compiler/qsc_linter/src/linter.rs b/compiler/qsc_linter/src/linter.rs
index 1be9eefc..c7ba2120 100644
--- a/compiler/qsc_linter/src/linter.rs
+++ b/compiler/qsc_linter/src/linter.rs
@@ -20,6 +20,20 @@ pub fn run_lints(
     package_store: &PackageStore,
     compile_unit: &CompileUnit,
     config: Option<&[LintConfig]>,
+) -> Vec<Lint> {
+    let mut lints = run_lints_without_deduplication(package_store, compile_unit, config);
+    remove_duplicates(&mut lints);
+    lints
+}
+
+/// This function is used by our unit tests, to make sure lints aren't duplicated under
+/// normal circunstances. The `run_lints` functions deduplicates the lints to take care
+/// of a few special cases where the same expression (referring to the same span in the
+/// source code) appears referenced in multiple places in the HIR.
+pub(crate) fn run_lints_without_deduplication(
+    package_store: &PackageStore,
+    compile_unit: &CompileUnit,
+    config: Option<&[LintConfig]>,
 ) -> Vec<Lint> {
     let compilation = Compilation {
         package_store,
@@ -35,6 +49,11 @@ pub fn run_lints(
     lints
 }
 
+pub(crate) fn remove_duplicates<T: Eq + std::hash::Hash + Clone>(vec: &mut Vec<T>) {
+    let mut seen = rustc_hash::FxHashSet::default();
+    vec.retain(|x| seen.insert(x.clone()));
+}
+
 #[derive(Clone, Copy)]
 pub(crate) struct Compilation<'a> {
     pub package_store: &'a PackageStore,
@@ -117,6 +136,21 @@ pub struct Lint {
     pub code_action_edits: Vec<(String, Span)>,
 }
 
+impl std::hash::Hash for Lint {
+    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+        self.span.hash(state);
+        self.kind.hash(state);
+    }
+}
+
+impl std::cmp::PartialEq for Lint {
+    fn eq(&self, other: &Self) -> bool {
+        self.span == other.span && self.kind == other.kind
+    }
+}
+
+impl std::cmp::Eq for Lint {}
+
 impl std::fmt::Display for Lint {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         write!(f, "{}", self.message)
@@ -188,7 +222,7 @@ pub struct LintConfig {
 }
 
 /// Represents a lint name.
-#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
+#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash)]
 #[serde(untagged)]
 pub enum LintKind {
     /// AST lint name.
diff --git a/compiler/qsc_linter/src/linter/ast.rs b/compiler/qsc_linter/src/linter/ast.rs
index b9a201ba..9befd4a0 100644
--- a/compiler/qsc_linter/src/linter/ast.rs
+++ b/compiler/qsc_linter/src/linter/ast.rs
@@ -185,7 +185,7 @@ macro_rules! declare_ast_lints {
         use serde::{Deserialize, Serialize};
 
         /// An enum listing all existing AST lints.
-        #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
+        #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash)]
         #[serde(rename_all = "camelCase")]
         pub enum AstLint {
             $(
diff --git a/compiler/qsc_linter/src/linter/hir.rs b/compiler/qsc_linter/src/linter/hir.rs
index 4c5e76ed..a8e0b6b7 100644
--- a/compiler/qsc_linter/src/linter/hir.rs
+++ b/compiler/qsc_linter/src/linter/hir.rs
@@ -149,7 +149,7 @@ macro_rules! declare_hir_lints {
         use serde::{Deserialize, Serialize};
 
         /// An enum listing all existing HIR lints.
-        #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
+        #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash)]
         #[serde(rename_all = "camelCase")]
         pub enum HirLint {
             $(
diff --git a/compiler/qsc_lowerer/src/lib.rs b/compiler/qsc_lowerer/src/lib.rs
index 3fb00841..34024504 100644
--- a/compiler/qsc_lowerer/src/lib.rs
+++ b/compiler/qsc_lowerer/src/lib.rs
@@ -943,7 +943,10 @@ fn lower_attrs(attrs: &[hir::Attr]) -> Vec<fir::Attr> {
             hir::Attr::EntryPoint => Some(fir::Attr::EntryPoint),
             hir::Attr::Measurement => Some(fir::Attr::Measurement),
             hir::Attr::Reset => Some(fir::Attr::Reset),
-            hir::Attr::SimulatableIntrinsic | hir::Attr::Unimplemented | hir::Attr::Config => None,
+            hir::Attr::SimulatableIntrinsic
+            | hir::Attr::Unimplemented
+            | hir::Attr::Config
+            | hir::Attr::Test => None,
         })
         .collect()
 }
diff --git a/compiler/qsc_passes/src/lib.rs b/compiler/qsc_passes/src/lib.rs
index c20a3816..72838140 100644
--- a/compiler/qsc_passes/src/lib.rs
+++ b/compiler/qsc_passes/src/lib.rs
@@ -15,6 +15,7 @@ mod measurement;
 mod replace_qubit_allocation;
 mod reset;
 mod spec_gen;
+mod test_attribute;
 
 use callable_limits::CallableLimits;
 use capabilitiesck::{check_supported_capabilities, lower_store, run_rca_pass};
@@ -52,6 +53,7 @@ pub enum Error {
     Measurement(measurement::Error),
     Reset(reset::Error),
     SpecGen(spec_gen::Error),
+    TestAttribute(test_attribute::TestAttributeError),
 }
 
 #[derive(Clone, Copy, Debug, PartialEq)]
@@ -121,6 +123,9 @@ impl PassContext {
         ReplaceQubitAllocation::new(core, assigner).visit_package(package);
         Validator::default().visit_package(package);
 
+        let test_attribute_errors = test_attribute::validate_test_attributes(package);
+        Validator::default().visit_package(package);
+
         callable_errors
             .into_iter()
             .map(Error::CallableLimits)
@@ -130,6 +135,7 @@ impl PassContext {
             .chain(entry_point_errors)
             .chain(measurement_decl_errors.into_iter().map(Error::Measurement))
             .chain(reset_decl_errors.into_iter().map(Error::Reset))
+            .chain(test_attribute_errors.into_iter().map(Error::TestAttribute))
             .collect()
     }
 
diff --git a/language_service/src/compilation.rs b/language_service/src/compilation.rs
index 253eab56..3521fad2 100644
--- a/language_service/src/compilation.rs
+++ b/language_service/src/compilation.rs
@@ -37,6 +37,7 @@ pub(crate) struct Compilation {
     pub compile_errors: Vec<compile::Error>,
     pub kind: CompilationKind,
     pub dependencies: FxHashMap<PackageId, Option<PackageAlias>>,
+    pub test_cases: Vec<(String, Span)>,
 }
 
 #[derive(Debug)]
@@ -46,6 +47,8 @@ pub(crate) enum CompilationKind {
     /// one or more sources, and a target profile.
     OpenProject {
         package_graph_sources: PackageGraphSources,
+        /// a human-readable name for the package (not a unique URI -- meant to be read by humans)
+        friendly_name: Arc<str>,
     },
     /// A Q# notebook. In a notebook compilation, the user package
     /// contains multiple `Source`s, with each source corresponding
@@ -62,6 +65,7 @@ impl Compilation {
         lints_config: &[LintConfig],
         package_graph_sources: PackageGraphSources,
         project_errors: Vec<project::Error>,
+        friendly_name: &Arc<str>,
     ) -> Self {
         let mut buildable_program =
             prepare_package_store(target_profile.into(), package_graph_sources.clone());
@@ -102,15 +106,19 @@ impl Compilation {
 
         run_linter_passes(&mut compile_errors, &package_store, unit, lints_config);
 
+        let test_cases = unit.package.get_test_callables();
+
         Self {
             package_store,
             user_package_id: package_id,
             kind: CompilationKind::OpenProject {
                 package_graph_sources,
+                friendly_name: friendly_name.clone(),
             },
             compile_errors,
             project_errors,
             dependencies: user_code_dependencies.into_iter().collect(),
+            test_cases,
         }
     }
 
@@ -218,16 +226,28 @@ impl Compilation {
             .chain(once((source_package_id, None)))
             .collect();
 
+        let test_cases = unit.package.get_test_callables();
+
         Self {
             package_store,
             user_package_id: package_id,
             compile_errors: errors,
             project_errors: project.as_ref().map_or_else(Vec::new, |p| p.errors.clone()),
             kind: CompilationKind::Notebook { project },
+            test_cases,
             dependencies,
         }
     }
 
+    /// Returns a human-readable compilation name if one exists.
+    /// Notebooks don't have human-readable compilation names.
+    pub fn friendly_project_name(&self) -> Option<Arc<str>> {
+        match &self.kind {
+            CompilationKind::OpenProject { friendly_name, .. } => Some(friendly_name.clone()),
+            CompilationKind::Notebook { .. } => None,
+        }
+    }
+
     /// Gets the `CompileUnit` associated with user (non-library) code.
     pub fn user_unit(&self) -> &CompileUnit {
         self.package_store
@@ -322,6 +342,7 @@ impl Compilation {
         let new = match self.kind {
             CompilationKind::OpenProject {
                 ref package_graph_sources,
+                ref friendly_name,
             } => Self::new(
                 package_type,
                 target_profile,
@@ -329,6 +350,7 @@ impl Compilation {
                 lints_config,
                 package_graph_sources.clone(),
                 Vec::new(), // project errors will stay the same
+                friendly_name,
             ),
             CompilationKind::Notebook { ref project } => Self::new_notebook(
                 sources.into_iter(),
diff --git a/language_service/src/completion.rs b/language_service/src/completion.rs
index 74ad3500..10884e71 100644
--- a/language_service/src/completion.rs
+++ b/language_service/src/completion.rs
@@ -115,6 +115,7 @@ fn expected_word_kinds(
     match &compilation.kind {
         CompilationKind::OpenProject {
             package_graph_sources,
+            ..
         } => possible_words_at_offset_in_source(
             source_contents,
             Some(source_name_relative),
@@ -165,6 +166,7 @@ fn collect_hardcoded_words(expected: WordKinds) -> Vec<Completion> {
                     ),
                     Completion::new("Measurement".to_string(), CompletionItemKind::Interface),
                     Completion::new("Reset".to_string(), CompletionItemKind::Interface),
+                    Completion::new("Test".to_string(), CompletionItemKind::Interface),
                 ]);
             }
             HardcodedIdentKind::Size => {
diff --git a/language_service/src/lib.rs b/language_service/src/lib.rs
index 983a58a8..cf9f9ceb 100644
--- a/language_service/src/lib.rs
+++ b/language_service/src/lib.rs
@@ -26,7 +26,7 @@ use futures_util::StreamExt;
 use log::{trace, warn};
 use protocol::{
     CodeAction, CodeLens, CompletionList, DiagnosticUpdate, Hover, NotebookMetadata, SignatureHelp,
-    TextEdit, WorkspaceConfigurationUpdate,
+    TestCallables, TextEdit, WorkspaceConfigurationUpdate,
 };
 use qsc::{
     line_column::{Encoding, Position, Range},
@@ -67,6 +67,9 @@ impl LanguageService {
     pub fn create_update_worker<'a>(
         &mut self,
         diagnostics_receiver: impl Fn(DiagnosticUpdate) + 'a,
+        // Callback which receives detected test callables and does something with them
+        // in the case of VS Code, updates the test explorer with them
+        test_callable_receiver: impl Fn(TestCallables) + 'a,
         project_host: impl JSProjectHost + 'static,
     ) -> UpdateWorker<'a> {
         assert!(self.state_updater.is_none());
@@ -75,7 +78,9 @@ impl LanguageService {
             updater: CompilationStateUpdater::new(
                 self.state.clone(),
                 diagnostics_receiver,
+                test_callable_receiver,
                 project_host,
+                self.position_encoding,
             ),
             recv,
         };
diff --git a/language_service/src/protocol.rs b/language_service/src/protocol.rs
index 4a75b701..8a6c2f34 100644
--- a/language_service/src/protocol.rs
+++ b/language_service/src/protocol.rs
@@ -3,6 +3,7 @@
 
 use miette::Diagnostic;
 use qsc::line_column::Range;
+use qsc::location::Location;
 use qsc::{compile, project};
 use qsc::{linter::LintConfig, project::Manifest, target::Profile, LanguageFeatures, PackageType};
 use thiserror::Error;
@@ -33,6 +34,23 @@ pub struct DiagnosticUpdate {
     pub errors: Vec<ErrorKind>,
 }
 
+#[derive(Debug)]
+pub struct TestCallable {
+    /// This is a string that represents the interpreter-ready name of the test callable.
+    /// i.e. "Main.TestCase". Call it by adding parens to the end, e.g. `Main.TestCase()`
+    pub callable_name: Arc<str>,
+    /// A string that represents the originating compilation URI of this callable
+    pub compilation_uri: Arc<str>,
+    pub location: Location,
+    /// A human readable name that represents the compilation.
+    pub friendly_name: Arc<str>,
+}
+
+#[derive(Debug)]
+pub struct TestCallables {
+    pub callables: Vec<TestCallable>,
+}
+
 #[derive(Debug)]
 pub enum CodeActionKind {
     Empty,
@@ -112,6 +130,7 @@ impl PartialEq for CompletionItem {
 impl Eq for CompletionItem {}
 
 use std::hash::{Hash, Hasher};
+use std::sync::Arc;
 
 impl Hash for CompletionItem {
     fn hash<H: Hasher>(&self, state: &mut H) {
diff --git a/language_service/src/state.rs b/language_service/src/state.rs
index 3eaed8c1..00a726fe 100644
--- a/language_service/src/state.rs
+++ b/language_service/src/state.rs
@@ -4,13 +4,16 @@
 #[cfg(test)]
 mod tests;
 
+use crate::protocol::TestCallable;
+
 use super::compilation::Compilation;
-use super::protocol::{DiagnosticUpdate, NotebookMetadata};
-use crate::protocol::{ErrorKind, WorkspaceConfigurationUpdate};
+use super::protocol::{
+    DiagnosticUpdate, ErrorKind, NotebookMetadata, TestCallables, WorkspaceConfigurationUpdate,
+};
 use log::{debug, trace};
 use miette::Diagnostic;
-use qsc::{compile, project};
-use qsc::{target::Profile, LanguageFeatures, PackageType};
+use qsc::line_column::Encoding;
+use qsc::{compile, project, target::Profile, LanguageFeatures, PackageType};
 use qsc_linter::LintConfig;
 use qsc_project::{FileSystemAsync, JSProjectHost, PackageCache, Project};
 use rustc_hash::{FxHashMap, FxHashSet};
@@ -102,24 +105,32 @@ pub(super) struct CompilationStateUpdater<'a> {
     /// Callback which will receive diagnostics (compilation errors)
     /// whenever a (re-)compilation occurs.
     diagnostics_receiver: Box<dyn Fn(DiagnosticUpdate) + 'a>,
+    /// Callback which will receive test callables whenever a (re-)compilation occurs.
+    test_callable_receiver: Box<dyn Fn(TestCallables) + 'a>,
     cache: RefCell<PackageCache>,
     /// Functions to interact with the host filesystem for project system operations.
     project_host: Box<dyn JSProjectHost>,
+    /// Encoding for converting between line/column and byte offsets.
+    position_encoding: Encoding,
 }
 
 impl<'a> CompilationStateUpdater<'a> {
     pub fn new(
         state: Rc<RefCell<CompilationState>>,
         diagnostics_receiver: impl Fn(DiagnosticUpdate) + 'a,
+        test_callable_receiver: impl Fn(TestCallables) + 'a,
         project_host: impl JSProjectHost + 'static,
+        position_encoding: Encoding,
     ) -> Self {
         Self {
             state,
             configuration: Configuration::default(),
             documents_with_errors: FxHashSet::default(),
             diagnostics_receiver: Box::new(diagnostics_receiver),
+            test_callable_receiver: Box::new(test_callable_receiver),
             cache: RefCell::default(),
             project_host: Box::new(project_host),
+            position_encoding,
         }
     }
 
@@ -174,7 +185,7 @@ impl<'a> CompilationStateUpdater<'a> {
 
         self.insert_buffer_aware_compilation(project);
 
-        self.publish_diagnostics();
+        self.publish_diagnostics_and_test_callables();
     }
 
     /// Attempts to resolve a manifest for the given document uri.
@@ -253,6 +264,7 @@ impl<'a> CompilationStateUpdater<'a> {
                 &configuration.lints_config,
                 loaded_project.package_graph_sources,
                 loaded_project.errors,
+                &loaded_project.name,
             );
 
             state
@@ -275,7 +287,7 @@ impl<'a> CompilationStateUpdater<'a> {
             }
         }
 
-        self.publish_diagnostics();
+        self.publish_diagnostics_and_test_callables();
     }
 
     /// Removes a document from the open documents map. If the
@@ -372,7 +384,7 @@ impl<'a> CompilationStateUpdater<'a> {
                 (compilation, notebook_configuration),
             );
         });
-        self.publish_diagnostics();
+        self.publish_diagnostics_and_test_callables();
     }
 
     pub(super) fn close_notebook_document(&mut self, notebook_uri: &str) {
@@ -390,13 +402,14 @@ impl<'a> CompilationStateUpdater<'a> {
             state.compilations.remove(notebook_uri);
         });
 
-        self.publish_diagnostics();
+        self.publish_diagnostics_and_test_callables();
     }
 
     // It gets really messy knowing when to clear diagnostics
     // when the document changes ownership between compilations, etc.
     // So let's do it the simplest way possible. Republish all the diagnostics every time.
-    fn publish_diagnostics(&mut self) {
+    fn publish_diagnostics_and_test_callables(&mut self) {
+        self.publish_test_callables();
         let last_docs_with_errors = take(&mut self.documents_with_errors);
         let mut docs_with_errors = FxHashSet::default();
 
@@ -503,7 +516,7 @@ impl<'a> CompilationStateUpdater<'a> {
             }
         });
 
-        self.publish_diagnostics();
+        self.publish_diagnostics_and_test_callables();
     }
 
     /// Borrows the compilation state immutably and invokes `f`.
@@ -533,6 +546,36 @@ impl<'a> CompilationStateUpdater<'a> {
         let mut state = self.state.borrow_mut();
         f(&mut state)
     }
+
+    fn publish_test_callables(&self) {
+        self.with_state(|state| {
+            // get test callables from each compilation
+            let callables: Vec<_> = state
+                .compilations
+                .iter()
+                .flat_map(|(compilation_uri, (compilation, _))| {
+                    compilation.test_cases.iter().map(move |(name, span)| {
+                        Some(TestCallable {
+                            compilation_uri: Arc::from(compilation_uri.as_ref()),
+                            callable_name: Arc::from(name.as_ref()),
+                            location: crate::qsc_utils::into_location(
+                                self.position_encoding,
+                                compilation,
+                                *span,
+                                compilation.user_package_id,
+                            ),
+                            // notebooks don't have human readable names -- we use this
+                            // to filter them out in the test explorer
+                            friendly_name: compilation.friendly_project_name()?,
+                        })
+                    })
+                })
+                .flatten()
+                .collect();
+
+            (self.test_callable_receiver)(TestCallables { callables });
+        });
+    }
 }
 
 impl CompilationState {
diff --git a/library/std/src/Std/Math.qs b/library/std/src/Std/Math.qs
index e3dc3913..7e9cdf11 100644
--- a/library/std/src/Std/Math.qs
+++ b/library/std/src/Std/Math.qs
@@ -11,7 +11,7 @@ import Std.Diagnostics.*;
 
 /// # Summary
 /// Returns a double-precision approximation of the
-/// matematical constant 𝝅 ≈ 3.14159265358979323846
+/// mathematical constant 𝝅 ≈ 3.14159265358979323846
 ///
 /// # Remarks
 /// Mathematical constant 𝝅 represents the ratio of the circumference
diff --git a/library/std/src/Std/Measurement.qs b/library/std/src/Std/Measurement.qs
index 4d814bb4..d588b168 100644
--- a/library/std/src/Std/Measurement.qs
+++ b/library/std/src/Std/Measurement.qs
@@ -34,7 +34,7 @@ operation MeasureAllZ(register : Qubit[]) : Result {
 ///
 /// # Description
 /// Measures each qubit in a register in the `Z` basis
-/// and retuns the result of each measurement.
+/// and returns the result of each measurement.
 /// This operation does not reset the measured qubits to the |0⟩ state,
 /// leaving them in the state that corresponds to the measurement results.
 ///
@@ -46,9 +46,9 @@ operation MeasureAllZ(register : Qubit[]) : Result {
 ///
 /// # Remarks
 /// Please note the following differences:
-/// - Operation `MeasureEachZ` performs one measurement for each qubit and retuns
+/// - Operation `MeasureEachZ` performs one measurement for each qubit and returns
 ///   an array of results. The operation does not reset the qubits.
-/// - Operation `MResetEachZ` performs one measurement for each qubit and retuns
+/// - Operation `MResetEachZ` performs one measurement for each qubit and returns
 ///   an array of results. The operation resets all qubits to |0⟩ state.
 /// - Operation `MeasureAllZ` performs a joint measurement on all qubits
 ///   and returns one result. The operation does not reset the qubits.
diff --git a/npm/qsharp/src/browser.ts b/npm/qsharp/src/browser.ts
index 258d8017..607b0116 100644
--- a/npm/qsharp/src/browser.ts
+++ b/npm/qsharp/src/browser.ts
@@ -168,12 +168,17 @@ export type {
   IStructStepResult,
   IWorkspaceEdit,
   ProjectLoader,
+  ITestDescriptor,
   VSDiagnostic,
 } from "../lib/web/qsc_wasm.js";
 export { type Dump, type ShotResult } from "./compiler/common.js";
 export { type CompilerState, type ProgramConfig } from "./compiler/compiler.js";
 export { QscEventTarget } from "./compiler/events.js";
-export type { LanguageServiceEvent } from "./language-service/language-service.js";
+export type {
+  LanguageServiceDiagnosticEvent,
+  LanguageServiceEvent,
+  LanguageServiceTestCallablesEvent,
+} from "./language-service/language-service.js";
 export { default as samples } from "./samples.generated.js";
 export { log, type LogLevel, type TargetProfile };
 export type {
diff --git a/npm/qsharp/src/compiler/compiler.ts b/npm/qsharp/src/compiler/compiler.ts
index 50f73cc3..453984c7 100644
--- a/npm/qsharp/src/compiler/compiler.ts
+++ b/npm/qsharp/src/compiler/compiler.ts
@@ -123,6 +123,9 @@ export class Compiler implements ICompiler {
       (uri: string, version: number | undefined, errors: VSDiagnostic[]) => {
         diags = errors;
       },
+      () => {
+        // do nothing; test callables are not reported in checkCode
+      },
       {
         readFile: async () => null,
         listDirectory: async () => [],
diff --git a/npm/qsharp/src/language-service/language-service.ts b/npm/qsharp/src/language-service/language-service.ts
index c27db6de..268b742b 100644
--- a/npm/qsharp/src/language-service/language-service.ts
+++ b/npm/qsharp/src/language-service/language-service.ts
@@ -16,6 +16,7 @@ import type {
   IWorkspaceEdit,
   LanguageService,
   VSDiagnostic,
+  ITestDescriptor,
 } from "../../lib/web/qsc_wasm.js";
 import { IProjectHost } from "../browser.js";
 import { log } from "../log.js";
@@ -26,8 +27,7 @@ import {
 } from "../workers/common.js";
 type QscWasm = typeof import("../../lib/web/qsc_wasm.js");
 
-// Only one event type for now
-export type LanguageServiceEvent = {
+export type LanguageServiceDiagnosticEvent = {
   type: "diagnostics";
   detail: {
     uri: string;
@@ -36,6 +36,17 @@ export type LanguageServiceEvent = {
   };
 };
 
+export type LanguageServiceTestCallablesEvent = {
+  type: "testCallables";
+  detail: {
+    callables: ITestDescriptor[];
+  };
+};
+
+export type LanguageServiceEvent =
+  | LanguageServiceDiagnosticEvent
+  | LanguageServiceTestCallablesEvent;
+
 // These need to be async/promise results for when communicating across a WebWorker, however
 // for running the compiler in the same thread the result will be synchronous (a resolved promise).
 export interface ILanguageService {
@@ -127,6 +138,7 @@ export class QSharpLanguageService implements ILanguageService {
 
     this.backgroundWork = this.languageService.start_background_work(
       this.onDiagnostics.bind(this),
+      this.onTestCallables.bind(this),
       host,
     );
   }
@@ -263,7 +275,8 @@ export class QSharpLanguageService implements ILanguageService {
     diagnostics: VSDiagnostic[],
   ) {
     try {
-      const event = new Event("diagnostics") as LanguageServiceEvent & Event;
+      const event = new Event("diagnostics") as LanguageServiceDiagnosticEvent &
+        Event;
       event.detail = {
         uri,
         version: version ?? 0,
@@ -274,6 +287,20 @@ export class QSharpLanguageService implements ILanguageService {
       log.error("Error in onDiagnostics", e);
     }
   }
+
+  async onTestCallables(callables: ITestDescriptor[]) {
+    try {
+      const event = new Event(
+        "testCallables",
+      ) as LanguageServiceTestCallablesEvent & Event;
+      event.detail = {
+        callables,
+      };
+      this.eventHandler.dispatchEvent(event);
+    } catch (e) {
+      log.error("Error in onTestCallables", e);
+    }
+  }
 }
 
 /**
@@ -283,7 +310,7 @@ export class QSharpLanguageService implements ILanguageService {
  */
 export const languageServiceProtocol: ServiceProtocol<
   ILanguageService,
-  LanguageServiceEvent
+  LanguageServiceDiagnosticEvent
 > = {
   class: QSharpLanguageService,
   methods: {
diff --git a/playground/src/editor.tsx b/playground/src/editor.tsx
index c624d78b..d3dcad9c 100644
--- a/playground/src/editor.tsx
+++ b/playground/src/editor.tsx
@@ -8,12 +8,12 @@ import {
   CompilerState,
   ICompilerWorker,
   ILanguageServiceWorker,
-  LanguageServiceEvent,
   QscEventTarget,
   VSDiagnostic,
   log,
   ProgramConfig,
   TargetProfile,
+  LanguageServiceDiagnosticEvent,
 } from "qsharp-lang";
 import { Exercise, getExerciseSources } from "qsharp-lang/katas-md";
 import { codeToCompressedBase64, lsRangeToMonacoRange } from "./utils.js";
@@ -311,7 +311,7 @@ export function Editor(props: {
         : [{ lint: "needlessOperation", level: "warn" }],
     });
 
-    function onDiagnostics(evt: LanguageServiceEvent) {
+    function onDiagnostics(evt: LanguageServiceDiagnosticEvent) {
       const diagnostics = evt.detail.diagnostics;
       errMarks.current.checkDiags = diagnostics;
       markErrors();
diff --git a/vscode/src/circuit.ts b/vscode/src/circuit.ts
index 075bdaf2..c26e3250 100644
--- a/vscode/src/circuit.ts
+++ b/vscode/src/circuit.ts
@@ -332,6 +332,7 @@ export function updateCircuitPanel(
     calculating?: boolean;
   },
 ) {
+  const panelId = params?.operation?.operation || projectName;
   const title = params?.operation
     ? `${params.operation.operation} with ${params.operation.totalNumQubits} input qubits`
     : projectName;
@@ -349,10 +350,9 @@ export function updateCircuitPanel(
   };
 
   const message = {
-    command: "circuit",
     props,
   };
-  sendMessageToPanel("circuit", reveal, message);
+  sendMessageToPanel({ panelType: "circuit", id: panelId }, reveal, message);
 }
 
 /**
diff --git a/vscode/src/common.ts b/vscode/src/common.ts
index 71de7e08..c695bf7b 100644
--- a/vscode/src/common.ts
+++ b/vscode/src/common.ts
@@ -3,7 +3,14 @@
 
 import { TextDocument, Uri, Range, Location } from "vscode";
 import { Utils } from "vscode-uri";
-import { ILocation, IRange, IWorkspaceEdit, VSDiagnostic } from "qsharp-lang";
+import {
+  getCompilerWorker,
+  ICompilerWorker,
+  ILocation,
+  IRange,
+  IWorkspaceEdit,
+  VSDiagnostic,
+} from "qsharp-lang";
 import * as vscode from "vscode";
 
 export const qsharpLanguageId = "qsharp";
@@ -32,7 +39,7 @@ export function basename(path: string): string | undefined {
   return path.replace(/\/+$/, "").split("/").pop();
 }
 
-export function toVscodeRange(range: IRange): Range {
+export function toVsCodeRange(range: IRange): Range {
   return new Range(
     range.start.line,
     range.start.character,
@@ -41,18 +48,18 @@ export function toVscodeRange(range: IRange): Range {
   );
 }
 
-export function toVscodeLocation(location: ILocation): any {
-  return new Location(Uri.parse(location.source), toVscodeRange(location.span));
+export function toVsCodeLocation(location: ILocation): Location {
+  return new Location(Uri.parse(location.source), toVsCodeRange(location.span));
 }
 
-export function toVscodeWorkspaceEdit(
+export function toVsCodeWorkspaceEdit(
   iWorkspaceEdit: IWorkspaceEdit,
 ): vscode.WorkspaceEdit {
   const workspaceEdit = new vscode.WorkspaceEdit();
   for (const [source, edits] of iWorkspaceEdit.changes) {
     const uri = vscode.Uri.parse(source, true);
     const vsEdits = edits.map((edit) => {
-      return new vscode.TextEdit(toVscodeRange(edit.range), edit.newText);
+      return new vscode.TextEdit(toVsCodeRange(edit.range), edit.newText);
     });
     workspaceEdit.set(uri, vsEdits);
   }
@@ -73,7 +80,7 @@ export function toVsCodeDiagnostic(d: VSDiagnostic): vscode.Diagnostic {
       break;
   }
   const vscodeDiagnostic = new vscode.Diagnostic(
-    toVscodeRange(d.range),
+    toVsCodeRange(d.range),
     d.message,
     severity,
   );
@@ -88,10 +95,18 @@ export function toVsCodeDiagnostic(d: VSDiagnostic): vscode.Diagnostic {
   if (d.related) {
     vscodeDiagnostic.relatedInformation = d.related.map((r) => {
       return new vscode.DiagnosticRelatedInformation(
-        toVscodeLocation(r.location),
+        toVsCodeLocation(r.location),
         r.message,
       );
     });
   }
   return vscodeDiagnostic;
 }
+
+export function loadCompilerWorker(extensionUri: vscode.Uri): ICompilerWorker {
+  const compilerWorkerScriptPath = vscode.Uri.joinPath(
+    extensionUri,
+    "./out/compilerWorker.js",
+  ).toString();
+  return getCompilerWorker(compilerWorkerScriptPath);
+}
diff --git a/vscode/src/debugger/output.ts b/vscode/src/debugger/output.ts
index 00dbb0da..bf180c24 100644
--- a/vscode/src/debugger/output.ts
+++ b/vscode/src/debugger/output.ts
@@ -1,7 +1,7 @@
 // Copyright (c) Microsoft Corporation.
 // Licensed under the MIT License.
 
-import { QscEventTarget } from "qsharp-lang";
+import { QscEventTarget, VSDiagnostic } from "qsharp-lang";
 
 function formatComplex(real: number, imag: number) {
   // Format -0 as 0
@@ -72,7 +72,12 @@ export function createDebugConsoleEventTarget(out: (message: string) => void) {
   });
 
   eventTarget.addEventListener("Result", (evt) => {
-    out(`${evt.detail.value}`);
+    // sometimes these are VS Diagnostics
+    if ((evt.detail.value as VSDiagnostic).message !== undefined) {
+      out(`${(evt.detail.value as VSDiagnostic).message}`);
+    } else {
+      out(`${evt.detail.value}`);
+    }
   });
 
   return eventTarget;
diff --git a/vscode/src/debugger/session.ts b/vscode/src/debugger/session.ts
index 0db074e7..67cf14bc 100644
--- a/vscode/src/debugger/session.ts
+++ b/vscode/src/debugger/session.ts
@@ -30,7 +30,7 @@ import {
   log,
 } from "qsharp-lang";
 import { updateCircuitPanel } from "../circuit";
-import { basename, isQsharpDocument, toVscodeRange } from "../common";
+import { basename, isQsharpDocument, toVsCodeRange } from "../common";
 import {
   DebugEvent,
   EventType,
@@ -134,7 +134,7 @@ export class QscDebugSession extends LoggingDebugSession {
             ),
           };
           return {
-            range: toVscodeRange(location.range),
+            range: toVsCodeRange(location.range),
             uiLocation,
             breakpoint: this.createBreakpoint(location.id, uiLocation),
           } as IBreakpointLocationData;
@@ -953,7 +953,10 @@ export class QscDebugSession extends LoggingDebugSession {
 
   /* Updates the circuit panel if `showCircuit` is true or if panel is already open */
   private async updateCircuit(error?: any) {
-    if (this.config.showCircuit || isPanelOpen("circuit")) {
+    if (
+      this.config.showCircuit ||
+      isPanelOpen("circuit", this.program.projectName)
+    ) {
       // Error returned from the debugger has a message and a stack (which also includes the message).
       // We would ideally retrieve the original runtime error, and format it to be consistent
       // with the other runtime errors that can be shown in the circuit panel, but that will require
diff --git a/vscode/src/documentation.ts b/vscode/src/documentation.ts
index d58da7c8..b644aa00 100644
--- a/vscode/src/documentation.ts
+++ b/vscode/src/documentation.ts
@@ -14,7 +14,7 @@ export async function showDocumentationCommand(extensionUri: Uri) {
 
   // Reveal panel and show 'Loading...' for immediate feedback.
   sendMessageToPanel(
-    "documentation", // This is needed to route the message to the proper panel
+    { panelType: "documentation" }, // This is needed to route the message to the proper panel
     true, // Reveal panel
     null, // With no message
   );
@@ -48,7 +48,7 @@ export async function showDocumentationCommand(extensionUri: Uri) {
   };
 
   sendMessageToPanel(
-    "documentation", // This is needed to route the message to the proper panel
+    { panelType: "documentation" }, // This is needed to route the message to the proper panel
     true, // Reveal panel
     message, // And ask it to display documentation
   );
diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts
index 750700f3..d5b54f6e 100644
--- a/vscode/src/extension.ts
+++ b/vscode/src/extension.ts
@@ -67,9 +67,7 @@ export async function activate(
 
   context.subscriptions.push(...activateTargetProfileStatusBarItem());
 
-  context.subscriptions.push(
-    ...(await activateLanguageService(context.extensionUri)),
-  );
+  context.subscriptions.push(...(await activateLanguageService(context)));
 
   context.subscriptions.push(...startOtherQSharpDiagnostics());
 
diff --git a/vscode/src/language-service/activate.ts b/vscode/src/language-service/activate.ts
index 89d407af..09a6f739 100644
--- a/vscode/src/language-service/activate.ts
+++ b/vscode/src/language-service/activate.ts
@@ -37,8 +37,15 @@ import { registerQSharpNotebookCellUpdateHandlers } from "./notebook.js";
 import { createReferenceProvider } from "./references.js";
 import { createRenameProvider } from "./rename.js";
 import { createSignatureHelpProvider } from "./signature.js";
-
-export async function activateLanguageService(extensionUri: vscode.Uri) {
+import { startTestDiscovery } from "./testExplorer.js";
+
+/**
+ * Returns all of the subscriptions that should be registered for the language service.
+ */
+export async function activateLanguageService(
+  context: vscode.ExtensionContext,
+): Promise<vscode.Disposable[]> {
+  const extensionUri = context.extensionUri;
   const subscriptions: vscode.Disposable[] = [];
 
   const languageService = await loadLanguageService(extensionUri);
@@ -46,6 +53,9 @@ export async function activateLanguageService(extensionUri: vscode.Uri) {
   // diagnostics
   subscriptions.push(...startLanguageServiceDiagnostics(languageService));
 
+  // test explorer
+  subscriptions.push(...startTestDiscovery(languageService, context));
+
   // synchronize document contents
   subscriptions.push(...registerDocumentUpdateHandlers(languageService));
 
@@ -147,7 +157,9 @@ export async function activateLanguageService(extensionUri: vscode.Uri) {
   return subscriptions;
 }
 
-async function loadLanguageService(baseUri: vscode.Uri) {
+async function loadLanguageService(
+  baseUri: vscode.Uri,
+): Promise<ILanguageService> {
   const start = performance.now();
   const wasmUri = vscode.Uri.joinPath(baseUri, "./wasm/qsc_wasm_bg.wasm");
   const wasmBytes = await vscode.workspace.fs.readFile(wasmUri);
@@ -168,7 +180,14 @@ async function loadLanguageService(baseUri: vscode.Uri) {
   );
   return languageService;
 }
-function registerDocumentUpdateHandlers(languageService: ILanguageService) {
+
+/**
+ * This function returns all of the subscriptions that should be registered for the language service.
+ * Additionally, if an `eventEmitter` is passed in, will fire an event when a document is updated.
+ */
+function registerDocumentUpdateHandlers(
+  languageService: ILanguageService,
+): vscode.Disposable[] {
   vscode.workspace.textDocuments.forEach((document) => {
     updateIfQsharpDocument(document);
   });
diff --git a/vscode/src/language-service/codeActions.ts b/vscode/src/language-service/codeActions.ts
index 513f28fe..c3c29b73 100644
--- a/vscode/src/language-service/codeActions.ts
+++ b/vscode/src/language-service/codeActions.ts
@@ -3,7 +3,7 @@
 
 import { ILanguageService, ICodeAction } from "qsharp-lang";
 import * as vscode from "vscode";
-import { toVscodeWorkspaceEdit } from "../common";
+import { toVsCodeWorkspaceEdit } from "../common";
 
 export function createCodeActionsProvider(languageService: ILanguageService) {
   return new QSharpCodeActionProvider(languageService);
@@ -31,7 +31,7 @@ function toCodeAction(iCodeAction: ICodeAction): vscode.CodeAction {
     toCodeActionKind(iCodeAction.kind),
   );
   if (iCodeAction.edit) {
-    codeAction.edit = toVscodeWorkspaceEdit(iCodeAction.edit);
+    codeAction.edit = toVsCodeWorkspaceEdit(iCodeAction.edit);
   }
   codeAction.isPreferred = iCodeAction.isPreferred;
   return codeAction;
diff --git a/vscode/src/language-service/codeLens.ts b/vscode/src/language-service/codeLens.ts
index 98672811..f5d95223 100644
--- a/vscode/src/language-service/codeLens.ts
+++ b/vscode/src/language-service/codeLens.ts
@@ -7,7 +7,7 @@ import {
   qsharpLibraryUriScheme,
 } from "qsharp-lang";
 import * as vscode from "vscode";
-import { toVscodeRange } from "../common";
+import { toVsCodeRange } from "../common";
 
 export function createCodeLensProvider(languageService: ILanguageService) {
   return new QSharpCodeLensProvider(languageService);
@@ -71,7 +71,7 @@ function mapCodeLens(cl: ICodeLens): vscode.CodeLens {
       break;
   }
 
-  return new vscode.CodeLens(toVscodeRange(cl.range), {
+  return new vscode.CodeLens(toVsCodeRange(cl.range), {
     title,
     command,
     arguments: args,
diff --git a/vscode/src/language-service/completion.ts b/vscode/src/language-service/completion.ts
index 92f2fc8b..444a46cd 100644
--- a/vscode/src/language-service/completion.ts
+++ b/vscode/src/language-service/completion.ts
@@ -4,7 +4,7 @@
 import { ILanguageService, samples } from "qsharp-lang";
 import * as vscode from "vscode";
 import { CompletionItem } from "vscode";
-import { toVscodeRange } from "../common";
+import { toVsCodeRange } from "../common";
 import { EventType, sendTelemetryEvent } from "../telemetry";
 
 export function createCompletionItemProvider(
@@ -84,7 +84,7 @@ class QSharpCompletionItemProvider implements vscode.CompletionItemProvider {
       item.sortText = c.sortText;
       item.detail = c.detail;
       item.additionalTextEdits = c.additionalTextEdits?.map((edit) => {
-        return new vscode.TextEdit(toVscodeRange(edit.range), edit.newText);
+        return new vscode.TextEdit(toVsCodeRange(edit.range), edit.newText);
       });
       return item;
     });
diff --git a/vscode/src/language-service/definition.ts b/vscode/src/language-service/definition.ts
index fb2f6a6a..3b2f8e16 100644
--- a/vscode/src/language-service/definition.ts
+++ b/vscode/src/language-service/definition.ts
@@ -3,7 +3,7 @@
 
 import { ILanguageService } from "qsharp-lang";
 import * as vscode from "vscode";
-import { toVscodeLocation } from "../common";
+import { toVsCodeLocation } from "../common";
 
 export function createDefinitionProvider(languageService: ILanguageService) {
   return new QSharpDefinitionProvider(languageService);
@@ -21,6 +21,6 @@ class QSharpDefinitionProvider implements vscode.DefinitionProvider {
       position,
     );
     if (!definition) return null;
-    return toVscodeLocation(definition);
+    return toVsCodeLocation(definition);
   }
 }
diff --git a/vscode/src/language-service/format.ts b/vscode/src/language-service/format.ts
index fb9275df..a3a2b7f7 100644
--- a/vscode/src/language-service/format.ts
+++ b/vscode/src/language-service/format.ts
@@ -3,7 +3,7 @@
 
 import { ILanguageService } from "qsharp-lang";
 import * as vscode from "vscode";
-import { toVscodeRange } from "../common";
+import { toVsCodeRange } from "../common";
 import { EventType, FormatEvent, sendTelemetryEvent } from "../telemetry";
 import { getRandomGuid } from "../utils";
 
@@ -50,7 +50,7 @@ class QSharpFormattingProvider
     }
 
     let edits = lsEdits.map(
-      (edit) => new vscode.TextEdit(toVscodeRange(edit.range), edit.newText),
+      (edit) => new vscode.TextEdit(toVsCodeRange(edit.range), edit.newText),
     );
 
     if (range) {
diff --git a/vscode/src/language-service/hover.ts b/vscode/src/language-service/hover.ts
index 43071740..be17cf20 100644
--- a/vscode/src/language-service/hover.ts
+++ b/vscode/src/language-service/hover.ts
@@ -3,7 +3,7 @@
 
 import { ILanguageService } from "qsharp-lang";
 import * as vscode from "vscode";
-import { toVscodeRange } from "../common";
+import { toVsCodeRange } from "../common";
 
 export function createHoverProvider(languageService: ILanguageService) {
   return new QSharpHoverProvider(languageService);
@@ -21,7 +21,7 @@ class QSharpHoverProvider implements vscode.HoverProvider {
       hover &&
       new vscode.Hover(
         new vscode.MarkdownString(hover.contents),
-        toVscodeRange(hover.span),
+        toVsCodeRange(hover.span),
       )
     );
   }
diff --git a/vscode/src/language-service/references.ts b/vscode/src/language-service/references.ts
index 528038c1..84ada029 100644
--- a/vscode/src/language-service/references.ts
+++ b/vscode/src/language-service/references.ts
@@ -3,7 +3,7 @@
 
 import { ILanguageService } from "qsharp-lang";
 import * as vscode from "vscode";
-import { toVscodeLocation } from "../common";
+import { toVsCodeLocation } from "../common";
 
 export function createReferenceProvider(languageService: ILanguageService) {
   return new QSharpReferenceProvider(languageService);
@@ -24,6 +24,6 @@ class QSharpReferenceProvider implements vscode.ReferenceProvider {
       context.includeDeclaration,
     );
     if (!lsReferences) return [];
-    return lsReferences.map(toVscodeLocation);
+    return lsReferences.map(toVsCodeLocation);
   }
 }
diff --git a/vscode/src/language-service/rename.ts b/vscode/src/language-service/rename.ts
index 02060ab4..7ae45ce2 100644
--- a/vscode/src/language-service/rename.ts
+++ b/vscode/src/language-service/rename.ts
@@ -3,7 +3,7 @@
 
 import { ILanguageService } from "qsharp-lang";
 import * as vscode from "vscode";
-import { toVscodeRange, toVscodeWorkspaceEdit } from "../common";
+import { toVsCodeRange, toVsCodeWorkspaceEdit } from "../common";
 
 export function createRenameProvider(languageService: ILanguageService) {
   return new QSharpRenameProvider(languageService);
@@ -25,7 +25,7 @@ class QSharpRenameProvider implements vscode.RenameProvider {
       newName,
     );
     if (!rename) return null;
-    return toVscodeWorkspaceEdit(rename);
+    return toVsCodeWorkspaceEdit(rename);
   }
 
   async prepareRename(
@@ -40,7 +40,7 @@ class QSharpRenameProvider implements vscode.RenameProvider {
     );
     if (prepareRename) {
       return {
-        range: toVscodeRange(prepareRename.range),
+        range: toVsCodeRange(prepareRename.range),
         placeholder: prepareRename.newText,
       };
     } else {
diff --git a/vscode/src/projectSystem.ts b/vscode/src/projectSystem.ts
index cff72476..935231f4 100644
--- a/vscode/src/projectSystem.ts
+++ b/vscode/src/projectSystem.ts
@@ -24,6 +24,14 @@ async function findManifestDocument(
   // vscode-vfs://github%2B7b2276223a312c22726566223a7b2274797065223a332c226964223a22383439227d7d/microsoft/qsharp/samples/shor.qs
   const currentDocumentUri = URI.parse(currentDocumentUriString);
 
+  // if this document is itself a manifest file, then we've found it
+  if (currentDocumentUri.path.endsWith("qsharp.json")) {
+    return {
+      directory: Utils.dirname(currentDocumentUri),
+      manifest: currentDocumentUri,
+    };
+  }
+
   // Untitled documents don't have a file location, thus can't have a manifest
   if (currentDocumentUri.scheme === "untitled") return null;
 
diff --git a/vscode/src/registry.json b/vscode/src/registry.json
index a1d8b89a..f8b713ba 100644
--- a/vscode/src/registry.json
+++ b/vscode/src/registry.json
@@ -26,7 +26,7 @@
             { "ref": "bd5a09c", "notes": "latest stable" },
             { "ref": "main", "notes": "nightly, unstable" }
           ],
-          "path": "library/signed"
+          "path": "library/fixed_point"
         }
       }
     },
diff --git a/vscode/src/webview/webview.tsx b/vscode/src/webview/webview.tsx
index 3a4c3a3d..4d91eba0 100644
--- a/vscode/src/webview/webview.tsx
+++ b/vscode/src/webview/webview.tsx
@@ -33,6 +33,7 @@ window.addEventListener("load", main);
 
 type HistogramState = {
   viewType: "histogram";
+  panelId: string;
   buckets: Array<[string, number]>;
   shotCount: number;
 };
@@ -47,6 +48,7 @@ type EstimatesState = {
 
 type CircuitState = {
   viewType: "circuit";
+  panelId: string;
   props: CircuitProps;
 };
 
@@ -57,13 +59,13 @@ type DocumentationState = {
 };
 
 type State =
-  | { viewType: "loading" }
+  | { viewType: "loading"; panelId: string }
   | { viewType: "help" }
   | HistogramState
   | EstimatesState
   | CircuitState
   | DocumentationState;
-const loadingState: State = { viewType: "loading" };
+const loadingState: State = { viewType: "loading", panelId: "" };
 const helpState: State = { viewType: "help" };
 let state: State = loadingState;
 
@@ -140,6 +142,7 @@ function onMessage(event: any) {
       }
       state = {
         viewType: "histogram",
+        panelId: message.panelId,
         buckets: message.buckets as Array<[string, number]>,
         shotCount: message.shotCount,
       };
diff --git a/vscode/src/webviewPanel.ts b/vscode/src/webviewPanel.ts
index 8373a151..e8aa02de 100644
--- a/vscode/src/webviewPanel.ts
+++ b/vscode/src/webviewPanel.ts
@@ -172,24 +172,24 @@ export function registerWebViewCommands(context: ExtensionContext) {
 
       log.info("RE params", params);
 
-      sendMessageToPanel("estimates", true, {
-        command: "estimates",
+      sendMessageToPanel({ panelType: "estimates" }, true, {
         calculating: true,
       });
 
+      const estimatePanel = getOrCreatePanel("estimates");
       // Ensure the name is unique
-      if (panelTypeToPanel["estimates"].state[runName] !== undefined) {
+      if (estimatePanel.state[runName] !== undefined) {
         let idx = 2;
         for (;;) {
           const newName = `${runName}-${idx}`;
-          if (panelTypeToPanel["estimates"].state[newName] === undefined) {
+          if (estimatePanel.state[newName] === undefined) {
             runName = newName;
             break;
           }
           idx++;
         }
       }
-      panelTypeToPanel["estimates"].state[runName] = true;
+      estimatePanel.state[runName] = true;
 
       // Start the worker, run the code, and send the results to the webview
       log.debug("Starting resource estimates worker.");
@@ -239,19 +239,17 @@ export function registerWebViewCommands(context: ExtensionContext) {
         clearTimeout(compilerTimeout);
 
         const message = {
-          command: "estimates",
           calculating: false,
           estimates,
         };
-        sendMessageToPanel("estimates", true, message);
+        sendMessageToPanel({ panelType: "estimates" }, true, message);
       } catch (e: any) {
         // Stop the 'calculating' animation
         const message = {
-          command: "estimates",
           calculating: false,
           estimates: [],
         };
-        sendMessageToPanel("estimates", false, message);
+        sendMessageToPanel({ panelType: "estimates" }, false, message);
 
         if (timedOut) {
           // Show a VS Code popup that a timeout occurred
@@ -273,10 +271,8 @@ export function registerWebViewCommands(context: ExtensionContext) {
 
   context.subscriptions.push(
     commands.registerCommand(`${qsharpExtensionId}.showHelp`, async () => {
-      const message = {
-        command: "help",
-      };
-      sendMessageToPanel("help", true, message);
+      const message = {};
+      sendMessageToPanel({ panelType: "help" }, true, message);
     }),
   );
 
@@ -296,6 +292,8 @@ export function registerWebViewCommands(context: ExtensionContext) {
         throw new Error(program.errorMsg);
       }
 
+      const panelId = program.programConfig.projectName;
+
       // Start the worker, run the code, and send the results to the webview
       const worker = getCompilerWorker(compilerWorkerScriptPath);
       const compilerTimeout = setTimeout(() => {
@@ -322,7 +320,11 @@ export function registerWebViewCommands(context: ExtensionContext) {
           return;
         }
 
-        sendMessageToPanel("histogram", true, undefined);
+        sendMessageToPanel(
+          { panelType: "histogram", id: panelId },
+          true,
+          undefined,
+        );
 
         const evtTarget = new QscEventTarget(true);
         evtTarget.addEventListener("uiResultsRefresh", () => {
@@ -336,11 +338,14 @@ export function registerWebViewCommands(context: ExtensionContext) {
             buckets.set(strKey, newValue);
           }
           const message = {
-            command: "histogram",
             buckets: Array.from(buckets.entries()),
             shotCount: resultCount,
           };
-          sendMessageToPanel("histogram", false, message);
+          sendMessageToPanel(
+            { panelType: "histogram", id: panelId },
+            false,
+            message,
+          );
         });
         const start = performance.now();
         sendTelemetryEvent(EventType.HistogramStart, { associationId }, {});
@@ -390,6 +395,12 @@ export function registerWebViewCommands(context: ExtensionContext) {
   );
 }
 
+type PanelDesc = {
+  title: string;
+  panel: QSharpWebViewPanel;
+  state: any;
+};
+
 type PanelType =
   | "histogram"
   | "estimates"
@@ -397,31 +408,55 @@ type PanelType =
   | "circuit"
   | "documentation";
 
-const panelTypeToPanel: Record<
-  PanelType,
-  { title: string; panel: QSharpWebViewPanel | undefined; state: any }
-> = {
-  histogram: { title: "Q# Histogram", panel: undefined, state: {} },
-  estimates: { title: "Q# Estimates", panel: undefined, state: {} },
-  circuit: { title: "Q# Circuit", panel: undefined, state: {} },
-  help: { title: "Q# Help", panel: undefined, state: {} },
-  documentation: {
-    title: "Q# Documentation",
-    panel: undefined,
-    state: {},
-  },
+const panels: Record<PanelType, { [id: string]: PanelDesc }> = {
+  histogram: {},
+  estimates: {},
+  circuit: {},
+  help: {},
+  documentation: {},
 };
 
-export function sendMessageToPanel(
-  panelType: PanelType,
-  reveal: boolean,
-  message: any,
-) {
-  const panelRecord = panelTypeToPanel[panelType];
-  if (!panelRecord.panel) {
-    const panel = window.createWebviewPanel(
+const panelTypeToTitle: Record<PanelType, string> = {
+  histogram: "Q# Histogram",
+  estimates: "Q# Estimates",
+  circuit: "Q# Circuit",
+  help: "Q# Help",
+  documentation: "Q# Documentation",
+};
+
+function getPanel(type: PanelType, id?: string): PanelDesc | undefined {
+  if (id) {
+    return panels[type][id];
+  } else {
+    return panels[type][""];
+  }
+}
+
+export function isPanelOpen(panelType: PanelType, id?: string): boolean {
+  return getPanel(panelType, id)?.panel !== undefined;
+}
+
+function createPanel(
+  type: PanelType,
+  id?: string,
+  webViewPanel?: WebviewPanel,
+): PanelDesc {
+  if (id == undefined) {
+    id = "";
+  }
+  if (webViewPanel) {
+    const title = webViewPanel.title;
+    const panel = new QSharpWebViewPanel(type, webViewPanel, id);
+    panels[type][id] = { title, panel, state: {} };
+    return panels[type][id];
+  } else {
+    let title = `${panelTypeToTitle[type]}`;
+    if (type == "circuit" || type == "histogram") {
+      title = title + ` ${id}`;
+    }
+    const newPanel = window.createWebviewPanel(
       QSharpWebViewType,
-      panelRecord.title,
+      title,
       {
         viewColumn: ViewColumn.Three,
         preserveFocus: true,
@@ -439,15 +474,29 @@ export function sendMessageToPanel(
       },
     );
 
-    panelRecord.panel = new QSharpWebViewPanel(panelType, panel);
+    const panel = new QSharpWebViewPanel(type, newPanel, id);
+    panels[type][id] = { title, panel, state: {} };
+    return panels[type][id];
   }
+}
 
-  if (reveal) panelRecord.panel.reveal(ViewColumn.Beside);
-  if (message) panelRecord.panel.sendMessage(message);
+function getOrCreatePanel(type: PanelType, id?: string): PanelDesc {
+  const panel = getPanel(type, id);
+  if (panel) {
+    return panel;
+  } else {
+    return createPanel(type, id);
+  }
 }
 
-export function isPanelOpen(panelType: PanelType) {
-  return panelTypeToPanel[panelType].panel !== undefined;
+export function sendMessageToPanel(
+  panel: { panelType: PanelType; id?: string },
+  reveal: boolean,
+  message: any,
+) {
+  const panelRecord = getOrCreatePanel(panel.panelType, panel.id);
+  if (reveal) panelRecord.panel.reveal(ViewColumn.Beside);
+  if (message) panelRecord.panel.sendMessage(message);
 }
 
 export class QSharpWebViewPanel {
@@ -458,8 +507,9 @@ export class QSharpWebViewPanel {
   constructor(
     private type: PanelType,
     private panel: WebviewPanel,
+    private id: string,
   ) {
-    log.info("Creating webview panel of type", type);
+    log.info(`Creating webview panel of type ${type} and id ${id}`);
     this.panel.onDidDispose(() => this.dispose());
 
     this.panel.webview.html = this._getWebviewContent(this.panel.webview);
@@ -505,6 +555,8 @@ export class QSharpWebViewPanel {
   }
 
   sendMessage(message: any) {
+    message.command = message.command || this.type;
+    message.panelId = message.panelId || this.id;
     if (this._ready) {
       log.debug("Sending message to webview", message);
       this.panel.webview.postMessage(message);
@@ -532,8 +584,11 @@ export class QSharpWebViewPanel {
 
   public dispose() {
     log.info("Disposing webview panel", this.type);
-    panelTypeToPanel[this.type].panel = undefined;
-    panelTypeToPanel[this.type].state = {};
+    const panel = getPanel(this.type, this.id);
+    if (panel) {
+      panel.state = {};
+      delete panels[this.type][this.id];
+    }
     this.panel.dispose();
   }
 }
@@ -543,6 +598,7 @@ export class QSharpViewViewPanelSerializer implements WebviewPanelSerializer {
     log.info("Deserializing webview panel", state);
 
     const panelType: PanelType = state?.viewType;
+    const id = state?.panelId;
 
     if (
       panelType !== "estimates" &&
@@ -559,14 +615,11 @@ export class QSharpViewViewPanelSerializer implements WebviewPanelSerializer {
       return;
     }
 
-    if (panelTypeToPanel[panelType].panel !== undefined) {
-      log.error("Panel of type already exists", panelType);
+    if (getPanel(panelType, id) !== undefined) {
+      log.error(`Panel of type ${panelType} and id ${id} already exists`);
       return;
     }
 
-    panelTypeToPanel[panelType].panel = new QSharpWebViewPanel(
-      panelType,
-      panel,
-    );
+    createPanel(panelType, id, panel);
   }
 }
diff --git a/wasm/src/language_service.rs b/wasm/src/language_service.rs
index 6f2f8079..76ef3a96 100644
--- a/wasm/src/language_service.rs
+++ b/wasm/src/language_service.rs
@@ -6,12 +6,13 @@ use crate::{
     line_column::{ILocation, IPosition, IRange, Location, Position, Range},
     project_system::ProjectHost,
     serializable_type,
+    test_discovery::TestDescriptor,
 };
 use qsc::{
     self, line_column::Encoding, linter::LintConfig, target::Profile, LanguageFeatures, PackageType,
 };
 use qsc_project::Manifest;
-use qsls::protocol::DiagnosticUpdate;
+use qsls::protocol::{DiagnosticUpdate, TestCallable, TestCallables};
 use rustc_hash::FxHashMap;
 use serde::{Deserialize, Serialize};
 use std::str::FromStr;
@@ -31,12 +32,10 @@ impl LanguageService {
 
     pub fn start_background_work(
         &mut self,
-        diagnostics_callback: DiagnosticsCallback,
+        diagnostics_callback: &DiagnosticsCallback,
+        test_callables_callback: &TestCallableCallback,
         host: ProjectHost,
     ) -> js_sys::Promise {
-        let diagnostics_callback =
-            crate::project_system::to_js_function(diagnostics_callback.obj, "diagnostics_callback");
-
         let diagnostics_callback = diagnostics_callback
             .dyn_ref::<js_sys::Function>()
             .expect("expected a valid JS function")
@@ -58,7 +57,45 @@ impl LanguageService {
                 )
                 .expect("callback should succeed");
         };
-        let mut worker = self.0.create_update_worker(diagnostics_callback, host);
+
+        let test_callables_callback = test_callables_callback
+            .dyn_ref::<js_sys::Function>()
+            .expect("expected a valid JS function")
+            .clone();
+
+        let test_callables_callback = move |update: TestCallables| {
+            let callables = update
+                .callables
+                .iter()
+                .map(
+                    |TestCallable {
+                         compilation_uri,
+                         callable_name,
+                         location,
+                         friendly_name,
+                     }|
+                     -> TestDescriptor {
+                        TestDescriptor {
+                            compilation_uri: compilation_uri.to_string(),
+                            callable_name: callable_name.to_string(),
+                            location: location.clone().into(),
+                            friendly_name: friendly_name.to_string(),
+                        }
+                    },
+                )
+                .collect::<Vec<_>>();
+
+            let _ = test_callables_callback
+                .call1(
+                    &JsValue::NULL,
+                    &serde_wasm_bindgen::to_value(&callables)
+                        .expect("conversion to TestCallables should succeed"),
+                )
+                .expect("callback should succeed");
+        };
+        let mut worker =
+            self.0
+                .create_update_worker(diagnostics_callback, test_callables_callback, host);
 
         future_to_promise(async move {
             worker.run().await;
@@ -587,3 +624,9 @@ extern "C" {
     )]
     pub type DiagnosticsCallback;
 }
+
+#[wasm_bindgen]
+extern "C" {
+    #[wasm_bindgen(typescript_type = "(callables: ITestDescriptor[]) => void")]
+    pub type TestCallableCallback;
+}
diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs
index edc8fb0a..a38a08ca 100644
--- a/wasm/src/lib.rs
+++ b/wasm/src/lib.rs
@@ -36,6 +36,7 @@ mod line_column;
 mod logging;
 mod project_system;
 mod serializable_type;
+mod test_discovery;
 
 #[cfg(test)]
 mod tests;
diff --git a/wasm/src/project_system.rs b/wasm/src/project_system.rs
index f1b7b626..a15699b1 100644
--- a/wasm/src/project_system.rs
+++ b/wasm/src/project_system.rs
@@ -73,15 +73,6 @@ extern "C" {
     fn profile(this: &ProgramConfig) -> String;
 }
 
-pub(crate) fn to_js_function(val: JsValue, help_text_panic: &'static str) -> js_sys::Function {
-    let js_ty = val.js_typeof();
-    assert!(
-        val.is_function(),
-        "expected a valid JS function ({help_text_panic}), received {js_ty:?}"
-    );
-    Into::<js_sys::Function>::into(val)
-}
-
 thread_local! { static PACKAGE_CACHE: Rc<RefCell<PackageCache>> = Rc::default(); }
 
 /// a minimal implementation for interacting with async JS filesystem callbacks to


Test patch:
diff --git a/compiler/qsc/src/interpret/circuit_tests.rs b/compiler/qsc/src/interpret/circuit_tests.rs
index 821b60b3..f3188cba 100644
--- a/compiler/qsc/src/interpret/circuit_tests.rs
+++ b/compiler/qsc/src/interpret/circuit_tests.rs
@@ -148,10 +148,8 @@ fn rotation_gate() {
         .circuit(CircuitEntryPoint::EntryPoint, false)
         .expect("circuit generation should succeed");
 
-    // The wire isn't visible here since the gate label is longer
-    // than the static column width, but we can live with it.
     expect![[r"
-        q_0     rx(1.5708)
+        q_0    ─ rx(1.5708) ──
     "]]
     .assert_eq(&circ.to_string());
 }
@@ -262,8 +260,8 @@ fn mresetz_unrestricted_profile() {
         .expect("circuit generation should succeed");
 
     expect![[r"
-        q_0    ── H ──── M ─── |0〉 ─
-                         ╘══════════
+        q_0    ── H ──── M ──── |0〉 ──
+                         ╘════════════
     "]]
     .assert_eq(&circ.to_string());
 }
@@ -349,10 +347,10 @@ fn unrestricted_profile_result_comparison() {
         .expect("circuit generation should succeed");
 
     expect![[r"
-        q_0    ── H ──── M ──── X ─── |0〉 ─
-                         ╘═════════════════
-        q_1    ── H ──── M ─── |0〉 ────────
-                         ╘═════════════════
+        q_0    ── H ──── M ───── X ───── |0〉 ──
+                         ╘═════════════════════
+        q_1    ── H ──── M ──── |0〉 ───────────
+                         ╘═════════════════════
     "]]
     .assert_eq(&circ.to_string());
 
@@ -366,10 +364,10 @@ fn unrestricted_profile_result_comparison() {
 
     let circuit = interpreter.get_circuit();
     expect![[r"
-        q_0    ── H ──── M ──── X ─── |0〉 ─
-                         ╘═════════════════
-        q_1    ── H ──── M ─── |0〉 ────────
-                         ╘═════════════════
+        q_0    ── H ──── M ───── X ───── |0〉 ──
+                         ╘═════════════════════
+        q_1    ── H ──── M ──── |0〉 ───────────
+                         ╘═════════════════════
     "]]
     .assert_eq(&circuit.to_string());
 }
@@ -456,10 +454,8 @@ fn custom_intrinsic_one_classical_arg() {
         .circuit(CircuitEntryPoint::EntryPoint, false)
         .expect("circuit generation should succeed");
 
-    // A custom intrinsic that doesn't take qubits just doesn't
-    // show up on the circuit.
     expect![[r"
-        q_0    ── X ── foo(4)
+        q_0    ── X ─── foo(4) ──
     "]]
     .assert_eq(&circ.to_string());
 }
@@ -497,16 +493,16 @@ fn custom_intrinsic_mixed_args() {
     // This is one gate that spans ten target wires, even though the
     // text visualization doesn't convey that clearly.
     expect![[r"
-        q_0     AccountForEstimatesInternal([(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)], 1)
-        q_1     AccountForEstimatesInternal([(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)], 1)
-        q_2     AccountForEstimatesInternal([(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)], 1)
-        q_3     AccountForEstimatesInternal([(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)], 1)
-        q_4     AccountForEstimatesInternal([(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)], 1)
-        q_5     AccountForEstimatesInternal([(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)], 1)
-        q_6     AccountForEstimatesInternal([(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)], 1)
-        q_7     AccountForEstimatesInternal([(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)], 1)
-        q_8     AccountForEstimatesInternal([(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)], 1)
-        q_9     AccountForEstimatesInternal([(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)], 1)
+        q_0    ─ AccountForEstimatesInternal([(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)], 1) ──
+        q_1    ─ AccountForEstimatesInternal([(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)], 1) ──
+        q_2    ─ AccountForEstimatesInternal([(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)], 1) ──
+        q_3    ─ AccountForEstimatesInternal([(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)], 1) ──
+        q_4    ─ AccountForEstimatesInternal([(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)], 1) ──
+        q_5    ─ AccountForEstimatesInternal([(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)], 1) ──
+        q_6    ─ AccountForEstimatesInternal([(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)], 1) ──
+        q_7    ─ AccountForEstimatesInternal([(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)], 1) ──
+        q_8    ─ AccountForEstimatesInternal([(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)], 1) ──
+        q_9    ─ AccountForEstimatesInternal([(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)], 1) ──
     "]]
     .assert_eq(&circ.to_string());
 
@@ -536,7 +532,7 @@ fn custom_intrinsic_apply_idle_noise() {
     // ConfigurePauliNoise has no qubit arguments so it shouldn't show up.
     // ApplyIdleNoise is a quantum operation so it shows up.
     expect![[r#"
-        q_0     ApplyIdleNoise
+        q_0    ─ ApplyIdleNoise ──
     "#]]
     .assert_eq(&circ.to_string());
 }
@@ -834,6 +830,63 @@ fn operation_with_non_qubit_args() {
     .assert_debug_eq(&circ_err);
 }
 
+#[test]
+fn operation_with_long_gates_properly_aligned() {
+    let mut interpreter = interpreter(
+        r"
+            namespace Test {
+                import Std.Measurement.*;
+
+                @EntryPoint()
+                operation Main() : Result[] {
+                    use q0 = Qubit();
+                    use q1 = Qubit();
+
+                    H(q0);
+                    H(q1);
+                    X(q1);
+                    Ry(1.0, q1);
+                    CNOT(q0, q1);
+                    M(q0);
+
+                    use q2 = Qubit();
+
+                    H(q2);
+                    Rx(1.0, q2);
+                    H(q2);
+                    Rx(1.0, q2);
+                    H(q2);
+                    Rx(1.0, q2);
+
+                    use q3 = Qubit();
+
+                    Rxx(1.0, q1, q3);
+
+                    CNOT(q0, q3);
+
+                    [M(q1), M(q3)]
+                }
+            }
+        ",
+        Profile::Unrestricted,
+    );
+
+    let circ = interpreter
+        .circuit(CircuitEntryPoint::EntryPoint, false)
+        .expect("circuit generation should succeed");
+
+    expect![[r#"
+        q_0    ── H ────────────────────────────────────── ● ──────── M ────────────────────────────────── ● ─────────
+                                                           │          ╘════════════════════════════════════╪══════════
+        q_1    ── H ──────── X ─────── ry(1.0000) ──────── X ───────────────────────────── rxx(1.0000) ────┼───── M ──
+                                                                                                ┆          │      ╘═══
+        q_2    ── H ─── rx(1.0000) ──────── H ─────── rx(1.0000) ──── H ─── rx(1.0000) ─────────┆──────────┼──────────
+        q_3    ─────────────────────────────────────────────────────────────────────────── rxx(1.0000) ─── X ──── M ──
+                                                                                                                  ╘═══
+    "#]]
+    .assert_eq(&circ.to_string());
+}
+
 /// Tests that invoke circuit generation throught the debugger.
 mod debugger_stepping {
     use super::Debugger;
@@ -915,17 +968,17 @@ mod debugger_stepping {
             step:
             q_0    ── H ──
             step:
-            q_0    ── H ──── Z ───────────────────────
-            q_1    ── H ──── ● ──── H ──── M ─── |0〉 ─
-                                           ╘══════════
+            q_0    ── H ──── Z ─────────────────────────
+            q_1    ── H ──── ● ──── H ──── M ──── |0〉 ──
+                                           ╘════════════
             step:
-            q_0    ── H ──── Z ─── |0〉 ───────────────
-            q_1    ── H ──── ● ──── H ──── M ─── |0〉 ─
-                                           ╘══════════
+            q_0    ── H ──── Z ──── |0〉 ──────────────────
+            q_1    ── H ──── ● ───── H ───── M ──── |0〉 ──
+                                             ╘════════════
             step:
-            q_0    ── H ──── Z ─── |0〉 ───────────────
-            q_1    ── H ──── ● ──── H ──── M ─── |0〉 ─
-                                           ╘══════════
+            q_0    ── H ──── Z ──── |0〉 ──────────────────
+            q_1    ── H ──── ● ───── H ───── M ──── |0〉 ──
+                                             ╘════════════
         "]]
         .assert_eq(&circs);
     }
@@ -959,11 +1012,11 @@ mod debugger_stepping {
             q_0    ── H ──── M ──
                              ╘═══
             step:
-            q_0    ── H ──── M ─── |0〉 ─
-                             ╘══════════
+            q_0    ── H ──── M ──── |0〉 ──
+                             ╘════════════
             step:
-            q_0    ── H ──── M ─── |0〉 ─
-                             ╘══════════
+            q_0    ── H ──── M ──── |0〉 ──
+                             ╘════════════
         "]]
         .assert_eq(&circs);
     }
diff --git a/compiler/qsc_circuit/src/circuit/tests.rs b/compiler/qsc_circuit/src/circuit/tests.rs
index 3a9f992a..d62e0c70 100644
--- a/compiler/qsc_circuit/src/circuit/tests.rs
+++ b/compiler/qsc_circuit/src/circuit/tests.rs
@@ -221,10 +221,8 @@ fn with_args() {
         }],
     };
 
-    // This looks wonky because the gate label is longer
-    // than the static column width, but we can live with it.
     expect![[r"
-        q_0     rx(1.5708)
+        q_0    ─ rx(1.5708) ──
     "]]
     .assert_eq(&c.to_string());
 }
@@ -258,12 +256,10 @@ fn two_targets() {
         ],
     };
 
-    // This looks wonky because the gate label is longer
-    // than the static column width, but we can live with it.
     expect![[r"
-        q_0     rzz(1.0000)
-        q_1    ───┆───
-        q_2     rzz(1.0000)
+        q_0    ─ rzz(1.0000) ─
+        q_1    ───────┆───────
+        q_2    ─ rzz(1.0000) ─
     "]]
     .assert_eq(&c.to_string());
 }
diff --git a/compiler/qsc_doc_gen/src/generate_docs/tests.rs b/compiler/qsc_doc_gen/src/generate_docs/tests.rs
index e471b3ce..195689e1 100644
--- a/compiler/qsc_doc_gen/src/generate_docs/tests.rs
+++ b/compiler/qsc_doc_gen/src/generate_docs/tests.rs
@@ -66,14 +66,14 @@ fn generates_unrestricted_item() {
         ---
         uid: Qdk.Std.Diagnostics.CheckZero
         title: CheckZero operation
-        description: "Q# CheckZero operation: Checks whether a qubit is in the \|0⟩ state, returning true if it is."
+        description: "Q# CheckZero operation: Checks whether a qubit is in the |0⟩ state, returning true if it is."
         ms.date: {TIMESTAMP}
         ms.topic: managed-reference
         qsharp.kind: operation
         qsharp.package: __Std__
         qsharp.namespace: Std.Diagnostics
         qsharp.name: CheckZero
-        qsharp.summary: "Checks whether a qubit is in the \|0⟩ state, returning true if it is."
+        qsharp.summary: "Checks whether a qubit is in the |0⟩ state, returning true if it is."
         ---
 
         # CheckZero operation
diff --git a/compiler/qsc_linter/src/tests.rs b/compiler/qsc_linter/src/tests.rs
index d6f4487f..efbeb273 100644
--- a/compiler/qsc_linter/src/tests.rs
+++ b/compiler/qsc_linter/src/tests.rs
@@ -2,15 +2,15 @@
 // Licensed under the MIT License.
 
 use crate::{
-    linter::{ast::run_ast_lints, hir::run_hir_lints, Compilation},
-    Lint, LintConfig, LintLevel,
+    linter::{remove_duplicates, run_lints_without_deduplication},
+    Lint, LintLevel,
 };
 use expect_test::{expect, Expect};
 use indoc::indoc;
 use qsc_data_structures::{
     language_features::LanguageFeatures, span::Span, target::TargetCapabilityFlags,
 };
-use qsc_frontend::compile::{self, CompileUnit, PackageStore, SourceMap};
+use qsc_frontend::compile::{self, PackageStore, SourceMap};
 use qsc_hir::hir::CallableKind;
 use qsc_passes::PackageType;
 
@@ -736,11 +736,33 @@ fn needless_operation_inside_function_call() {
     );
 }
 
-fn check(source: &str, expected: &Expect) {
-    let source = wrap_in_namespace(source);
+#[test]
+fn check_that_hir_lints_are_deduplicated_in_operations_with_multiple_specializations() {
+    check_with_deduplication(
+        "
+        operation Main() : Unit {}
+        operation LintProblem() : Unit is Adj + Ctl {
+            use q = Qubit();
+            0.0 == 0.0;
+        }",
+        &expect![[r#"
+            [
+                SrcLint {
+                    source: "0.0 == 0.0",
+                    level: Warn,
+                    message: "strict comparison of doubles",
+                    help: "consider comparing them with some margin of error",
+                    code_action_edits: [],
+                },
+            ]
+        "#]],
+    );
+}
+
+fn compile_and_collect_lints(source: &str) -> Vec<Lint> {
     let mut store = PackageStore::new(compile::core());
     let std = store.insert(compile::std(&store, TargetCapabilityFlags::all()));
-    let sources = SourceMap::new([("source.qs".into(), source.clone().into())], None);
+    let sources = SourceMap::new([("source.qs".into(), source.into())], None);
     let (unit, _) = qsc::compile::compile(
         &store,
         &[(std, None)],
@@ -753,11 +775,26 @@ fn check(source: &str, expected: &Expect) {
     let id = store.insert(unit);
     let unit = store.get(id).expect("user package should exist");
 
-    let actual: Vec<SrcLint> = run_lints(&store, unit, None)
+    run_lints_without_deduplication(&store, unit, None)
+}
+
+fn check(source: &str, expected: &Expect) {
+    let source = wrap_in_namespace(source);
+    let actual: Vec<_> = compile_and_collect_lints(&source)
         .into_iter()
         .map(|lint| SrcLint::from(&lint, &source))
         .collect();
+    expected.assert_debug_eq(&actual);
+}
 
+fn check_with_deduplication(source: &str, expected: &Expect) {
+    let source = wrap_in_namespace(source);
+    let mut lints = compile_and_collect_lints(&source);
+    remove_duplicates(&mut lints);
+    let actual: Vec<_> = lints
+        .into_iter()
+        .map(|lint| SrcLint::from(&lint, &source))
+        .collect();
     expected.assert_debug_eq(&actual);
 }
 
@@ -805,21 +842,3 @@ impl SrcLint {
         }
     }
 }
-
-fn run_lints(
-    package_store: &PackageStore,
-    compile_unit: &CompileUnit,
-    config: Option<&[LintConfig]>,
-) -> Vec<Lint> {
-    let compilation = Compilation {
-        package_store,
-        compile_unit,
-    };
-
-    let mut ast_lints = run_ast_lints(&compile_unit.ast.package, config, compilation);
-    let mut hir_lints = run_hir_lints(&compile_unit.package, config, compilation);
-    let mut lints = Vec::new();
-    lints.append(&mut ast_lints);
-    lints.append(&mut hir_lints);
-    lints
-}
diff --git a/compiler/qsc_parse/src/item/tests.rs b/compiler/qsc_parse/src/item/tests.rs
index e94d7168..64dd8796 100644
--- a/compiler/qsc_parse/src/item/tests.rs
+++ b/compiler/qsc_parse/src/item/tests.rs
@@ -2396,3 +2396,20 @@ fn top_level_nodes_error_recovery() {
             ]"#]],
     );
 }
+
+#[test]
+fn test_attribute() {
+    check(
+        parse,
+        "@Test() function Foo() : Unit {}",
+        &expect![[r#"
+            Item _id_ [0-32]:
+                Attr _id_ [0-7] (Ident _id_ [1-5] "Test"):
+                    Expr _id_ [5-7]: Unit
+                Callable _id_ [8-32] (Function):
+                    name: Ident _id_ [17-20] "Foo"
+                    input: Pat _id_ [20-22]: Unit
+                    output: Type _id_ [25-29]: Path: Path _id_ [25-29] (Ident _id_ [25-29] "Unit")
+                    body: Block: Block _id_ [30-32]: <empty>"#]],
+    );
+}
diff --git a/compiler/qsc_passes/src/test_attribute.rs b/compiler/qsc_passes/src/test_attribute.rs
new file mode 100644
index 00000000..ac9d1a67
--- /dev/null
+++ b/compiler/qsc_passes/src/test_attribute.rs
@@ -0,0 +1,47 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use miette::Diagnostic;
+use qsc_data_structures::span::Span;
+use qsc_hir::{hir::Attr, visit::Visitor};
+use thiserror::Error;
+
+#[cfg(test)]
+mod tests;
+
+#[derive(Clone, Debug, Diagnostic, Error)]
+pub enum TestAttributeError {
+    #[error("test callables cannot take arguments")]
+    CallableHasParameters(#[label] Span),
+    #[error("test callables cannot have type parameters")]
+    CallableHasTypeParameters(#[label] Span),
+}
+
+pub(crate) fn validate_test_attributes(
+    package: &mut qsc_hir::hir::Package,
+) -> Vec<TestAttributeError> {
+    let mut validator = TestAttributeValidator { errors: Vec::new() };
+    validator.visit_package(package);
+    validator.errors
+}
+
+struct TestAttributeValidator {
+    errors: Vec<TestAttributeError>,
+}
+
+impl<'a> Visitor<'a> for TestAttributeValidator {
+    fn visit_callable_decl(&mut self, decl: &'a qsc_hir::hir::CallableDecl) {
+        if decl.attrs.iter().any(|attr| matches!(attr, Attr::Test)) {
+            if !decl.generics.is_empty() {
+                self.errors
+                    .push(TestAttributeError::CallableHasTypeParameters(
+                        decl.name.span,
+                    ));
+            }
+            if decl.input.ty != qsc_hir::ty::Ty::UNIT {
+                self.errors
+                    .push(TestAttributeError::CallableHasParameters(decl.span));
+            }
+        }
+    }
+}
diff --git a/compiler/qsc_passes/src/test_attribute/tests.rs b/compiler/qsc_passes/src/test_attribute/tests.rs
new file mode 100644
index 00000000..1597a38e
--- /dev/null
+++ b/compiler/qsc_passes/src/test_attribute/tests.rs
@@ -0,0 +1,129 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use expect_test::{expect, Expect};
+use indoc::indoc;
+use qsc_data_structures::{language_features::LanguageFeatures, target::TargetCapabilityFlags};
+use qsc_frontend::compile::{self, compile, PackageStore, SourceMap};
+use qsc_hir::{validate::Validator, visit::Visitor};
+
+use crate::test_attribute::validate_test_attributes;
+
+fn check(file: &str, expect: &Expect) {
+    let store = PackageStore::new(compile::core());
+    let sources = SourceMap::new([("test".into(), file.into())], None);
+    let mut unit = compile(
+        &store,
+        &[],
+        sources,
+        TargetCapabilityFlags::all(),
+        LanguageFeatures::default(),
+    );
+    assert!(unit.errors.is_empty(), "{:?}", unit.errors);
+
+    let errors = validate_test_attributes(&mut unit.package);
+    Validator::default().visit_package(&unit.package);
+    if errors.is_empty() {
+        expect.assert_eq(&unit.package.to_string());
+    } else {
+        expect.assert_debug_eq(&errors);
+    }
+}
+
+#[test]
+fn callable_cant_have_params() {
+    check(
+        indoc! {"
+        namespace test {
+            @Test()
+            operation A(q : Qubit) : Unit {
+
+            }
+        }
+        "},
+        &expect![[r#"
+            [
+                CallableHasParameters(
+                    Span {
+                        lo: 33,
+                        hi: 71,
+                    },
+                ),
+            ]
+        "#]],
+    );
+}
+
+#[test]
+fn callable_cant_have_type_params() {
+    check(
+        indoc! {"
+        namespace test {
+            @Test()
+            operation A<'T>() : Unit {
+
+            }
+        }
+        "},
+        &expect![[r#"
+            [
+                CallableHasTypeParameters(
+                    Span {
+                        lo: 43,
+                        hi: 44,
+                    },
+                ),
+            ]
+        "#]],
+    );
+}
+
+#[test]
+fn conditionally_compile_out_test() {
+    check(
+        indoc! {"
+        namespace test {
+            @Test()
+            @Config(Base)
+            operation A<'T>() : Unit {
+
+            }
+        }
+        "},
+        &expect![[r#"
+            Package:
+                Item 0 [0-86] (Public):
+                    Namespace (Ident 0 [10-14] "test"): <empty>"#]],
+    );
+}
+
+#[test]
+fn callable_is_valid_test_callable() {
+    check(
+        indoc! {"
+        namespace test {
+            @Test()
+            operation A() : Unit {
+
+            }
+        }
+        "},
+        &expect![[r#"
+            Package:
+                Item 0 [0-64] (Public):
+                    Namespace (Ident 5 [10-14] "test"): Item 1
+                Item 1 [21-62] (Internal):
+                    Parent: 0
+                    Test
+                    Callable 0 [33-62] (operation):
+                        name: Ident 1 [43-44] "A"
+                        input: Pat 2 [44-46] [Type Unit]: Unit
+                        output: Unit
+                        functors: empty set
+                        body: SpecDecl 3 [33-62]: Impl:
+                            Block 4 [54-62]: <empty>
+                        adj: <none>
+                        ctl: <none>
+                        ctl-adj: <none>"#]],
+    );
+}
diff --git a/language_service/src/state/tests.rs b/language_service/src/state/tests.rs
index f115accb..9e5d7a4a 100644
--- a/language_service/src/state/tests.rs
+++ b/language_service/src/state/tests.rs
@@ -6,12 +6,12 @@
 
 use super::{CompilationState, CompilationStateUpdater};
 use crate::{
-    protocol::{DiagnosticUpdate, NotebookMetadata, WorkspaceConfigurationUpdate},
+    protocol::{DiagnosticUpdate, NotebookMetadata, TestCallables, WorkspaceConfigurationUpdate},
     tests::test_fs::{dir, file, FsNode, TestProjectHost},
 };
 use expect_test::{expect, Expect};
 use miette::Diagnostic;
-use qsc::{target::Profile, LanguageFeatures, PackageType};
+use qsc::{line_column::Encoding, target::Profile, LanguageFeatures, PackageType};
 use qsc_linter::{AstLint, LintConfig, LintKind, LintLevel};
 use std::{
     cell::RefCell,
@@ -23,7 +23,8 @@ use std::{
 #[tokio::test]
 async fn no_error() {
     let errors = RefCell::new(Vec::new());
-    let mut updater = new_updater(&errors);
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater(&errors, &test_cases);
 
     updater
         .update_document(
@@ -39,7 +40,8 @@ async fn no_error() {
 #[tokio::test]
 async fn clear_error() {
     let errors = RefCell::new(Vec::new());
-    let mut updater = new_updater(&errors);
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater(&errors, &test_cases);
 
     updater
         .update_document("single/foo.qs", 1, "namespace {")
@@ -76,7 +78,8 @@ async fn clear_error() {
 #[tokio::test]
 async fn close_last_doc_in_project() {
     let received_errors = RefCell::new(Vec::new());
-    let mut updater = new_updater(&received_errors);
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater(&received_errors, &test_cases);
 
     updater
         .update_document(
@@ -141,7 +144,9 @@ async fn close_last_doc_in_project() {
 #[tokio::test]
 async fn clear_on_document_close() {
     let errors = RefCell::new(Vec::new());
-    let mut updater = new_updater(&errors);
+    let test_cases = RefCell::new(Vec::new());
+
+    let mut updater = new_updater(&errors, &test_cases);
 
     updater
         .update_document("single/foo.qs", 1, "namespace {")
@@ -172,7 +177,8 @@ async fn clear_on_document_close() {
 #[tokio::test]
 async fn compile_error() {
     let errors = RefCell::new(Vec::new());
-    let mut updater = new_updater(&errors);
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater(&errors, &test_cases);
 
     updater
         .update_document("single/foo.qs", 1, "badsyntax")
@@ -193,7 +199,8 @@ async fn compile_error() {
 #[tokio::test]
 async fn rca_errors_are_reported_when_compilation_succeeds() {
     let errors = RefCell::new(Vec::new());
-    let mut updater = new_updater(&errors);
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater(&errors, &test_cases);
 
     updater.update_configuration(WorkspaceConfigurationUpdate {
         target_profile: Some(Profile::AdaptiveRI),
@@ -223,7 +230,8 @@ async fn rca_errors_are_reported_when_compilation_succeeds() {
 #[tokio::test]
 async fn base_profile_rca_errors_are_reported_when_compilation_succeeds() {
     let errors = RefCell::new(Vec::new());
-    let mut updater = new_updater(&errors);
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater(&errors, &test_cases);
 
     updater.update_configuration(WorkspaceConfigurationUpdate {
         target_profile: Some(Profile::Base),
@@ -255,7 +263,8 @@ async fn base_profile_rca_errors_are_reported_when_compilation_succeeds() {
 #[tokio::test]
 async fn package_type_update_causes_error() {
     let errors = RefCell::new(Vec::new());
-    let mut updater = new_updater(&errors);
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater(&errors, &test_cases);
 
     updater.update_configuration(WorkspaceConfigurationUpdate {
         package_type: Some(PackageType::Lib),
@@ -291,7 +300,8 @@ async fn package_type_update_causes_error() {
 #[tokio::test]
 async fn target_profile_update_fixes_error() {
     let errors = RefCell::new(Vec::new());
-    let mut updater = new_updater(&errors);
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater(&errors, &test_cases);
 
     updater.update_configuration(WorkspaceConfigurationUpdate {
         target_profile: Some(Profile::Base),
@@ -335,7 +345,8 @@ async fn target_profile_update_fixes_error() {
 #[tokio::test]
 async fn target_profile_update_causes_error_in_stdlib() {
     let errors = RefCell::new(Vec::new());
-    let mut updater = new_updater(&errors);
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater(&errors, &test_cases);
 
     updater.update_document(
         "single/foo.qs",
@@ -365,7 +376,8 @@ async fn target_profile_update_causes_error_in_stdlib() {
 #[tokio::test]
 async fn notebook_document_no_errors() {
     let errors = RefCell::new(Vec::new());
-    let mut updater = new_updater(&errors);
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater(&errors, &test_cases);
 
     updater
         .update_notebook_document(
@@ -385,7 +397,8 @@ async fn notebook_document_no_errors() {
 #[tokio::test]
 async fn notebook_document_errors() {
     let errors = RefCell::new(Vec::new());
-    let mut updater = new_updater(&errors);
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater(&errors, &test_cases);
 
     updater
         .update_notebook_document(
@@ -416,7 +429,8 @@ async fn notebook_document_errors() {
 #[tokio::test]
 async fn notebook_document_lints() {
     let errors = RefCell::new(Vec::new());
-    let mut updater = new_updater(&errors);
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater(&errors, &test_cases);
 
     updater
         .update_notebook_document(
@@ -450,7 +464,8 @@ async fn notebook_document_lints() {
 #[tokio::test]
 async fn notebook_update_remove_cell_clears_errors() {
     let errors = RefCell::new(Vec::new());
-    let mut updater = new_updater(&errors);
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater(&errors, &test_cases);
 
     updater
         .update_notebook_document(
@@ -497,7 +512,8 @@ async fn notebook_update_remove_cell_clears_errors() {
 #[tokio::test]
 async fn close_notebook_clears_errors() {
     let errors = RefCell::new(Vec::new());
-    let mut updater = new_updater(&errors);
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater(&errors, &test_cases);
 
     updater
         .update_notebook_document(
@@ -557,7 +573,9 @@ async fn update_notebook_with_valid_dependencies() {
 
     let fs = Rc::new(RefCell::new(fs));
     let errors = RefCell::new(Vec::new());
-    let mut updater = new_updater_with_file_system(&errors, &fs);
+    let test_cases = RefCell::new(Vec::new());
+
+    let mut updater = new_updater_with_file_system(&errors, &test_cases, &fs);
 
     updater
         .update_notebook_document(
@@ -597,7 +615,9 @@ async fn update_notebook_reports_errors_from_dependencies() {
 
     let fs = Rc::new(RefCell::new(fs));
     let errors = RefCell::new(Vec::new());
-    let mut updater = new_updater_with_file_system(&errors, &fs);
+    let test_cases = RefCell::new(Vec::new());
+
+    let mut updater = new_updater_with_file_system(&errors, &test_cases, &fs);
 
     updater
         .update_notebook_document(
@@ -675,7 +695,9 @@ async fn update_notebook_reports_errors_from_dependency_of_dependencies() {
 
     let fs = Rc::new(RefCell::new(fs));
     let errors = RefCell::new(Vec::new());
-    let mut updater = new_updater_with_file_system(&errors, &fs);
+    let test_cases = RefCell::new(Vec::new());
+
+    let mut updater = new_updater_with_file_system(&errors, &test_cases, &fs);
 
     updater
         .update_notebook_document(
@@ -705,7 +727,8 @@ async fn update_notebook_reports_errors_from_dependency_of_dependencies() {
 #[tokio::test]
 async fn update_doc_updates_project() {
     let received_errors = RefCell::new(Vec::new());
-    let mut updater = new_updater(&received_errors);
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater(&received_errors, &test_cases);
 
     updater
         .update_document(
@@ -758,6 +781,7 @@ async fn update_doc_updates_project() {
 #[tokio::test]
 async fn file_not_in_files_list() {
     let received_errors = RefCell::new(Vec::new());
+    let test_cases = RefCell::new(Vec::new());
 
     // Manifest has a "files" field.
     // One file is listed in it, the other is not.
@@ -788,7 +812,7 @@ async fn file_not_in_files_list() {
     );
 
     let fs = Rc::new(RefCell::new(fs));
-    let mut updater = new_updater_with_file_system(&received_errors, &fs);
+    let mut updater = new_updater_with_file_system(&received_errors, &test_cases, &fs);
 
     // Open the file that is listed in the files list
     updater
@@ -866,6 +890,7 @@ async fn file_not_in_files_list() {
 #[tokio::test]
 async fn file_not_under_src() {
     let received_errors = RefCell::new(Vec::new());
+    let test_cases = RefCell::new(Vec::new());
 
     // One file lives under the 'src' directory, the other does not.
     // The one that isn't under 'src' should not be associated with the project.
@@ -890,7 +915,7 @@ async fn file_not_under_src() {
     );
 
     let fs = Rc::new(RefCell::new(fs));
-    let mut updater = new_updater_with_file_system(&received_errors, &fs);
+    let mut updater = new_updater_with_file_system(&received_errors, &test_cases, &fs);
 
     // Open the file that is not under src.
     updater
@@ -964,7 +989,8 @@ async fn file_not_under_src() {
 #[tokio::test]
 async fn close_doc_prioritizes_fs() {
     let received_errors = RefCell::new(Vec::new());
-    let mut updater = new_updater(&received_errors);
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater(&received_errors, &test_cases);
 
     updater
         .update_document(
@@ -1016,7 +1042,8 @@ async fn close_doc_prioritizes_fs() {
 #[tokio::test]
 async fn delete_manifest() {
     let received_errors = RefCell::new(Vec::new());
-    let mut updater = new_updater(&received_errors);
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater(&received_errors, &test_cases);
 
     updater
         .update_document(
@@ -1077,7 +1104,8 @@ async fn delete_manifest() {
 #[tokio::test]
 async fn delete_manifest_then_close() {
     let received_errors = RefCell::new(Vec::new());
-    let mut updater = new_updater(&received_errors);
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater(&received_errors, &test_cases);
 
     updater
         .update_document(
@@ -1122,7 +1150,8 @@ async fn delete_manifest_then_close() {
 #[tokio::test]
 async fn doc_switches_project() {
     let received_errors = RefCell::new(Vec::new());
-    let mut updater = new_updater(&received_errors);
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater(&received_errors, &test_cases);
 
     updater
         .update_document("nested_projects/src/subdir/src/a.qs", 1, "namespace A {}")
@@ -1202,7 +1231,8 @@ async fn doc_switches_project() {
 #[tokio::test]
 async fn doc_switches_project_on_close() {
     let received_errors = RefCell::new(Vec::new());
-    let mut updater = new_updater(&received_errors);
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater(&received_errors, &test_cases);
 
     updater
         .update_document("nested_projects/src/subdir/src/a.qs", 1, "namespace A {}")
@@ -1298,7 +1328,9 @@ async fn loading_lints_config_from_manifest() {
 
     let fs = Rc::new(RefCell::new(fs));
     let received_errors = RefCell::new(Vec::new());
-    let updater = new_updater_with_file_system(&received_errors, &fs);
+    let test_cases = RefCell::new(Vec::new());
+
+    let updater = new_updater_with_file_system(&received_errors, &test_cases, &fs);
 
     // Check the LintConfig.
     check_lints_config(
@@ -1350,7 +1382,9 @@ async fn lints_update_after_manifest_change() {
 
     let fs = Rc::new(RefCell::new(fs));
     let received_errors = RefCell::new(Vec::new());
-    let mut updater = new_updater_with_file_system(&received_errors, &fs);
+    let test_cases = RefCell::new(Vec::new());
+
+    let mut updater = new_updater_with_file_system(&received_errors, &test_cases, &fs);
 
     // Trigger a document update.
     updater
@@ -1403,7 +1437,8 @@ async fn lints_prefer_workspace_over_defaults() {
         "namespace Foo { @EntryPoint() function Main() : Unit { let x = 5 / 0 + (2 ^ 4); } }";
 
     let received_errors = RefCell::new(Vec::new());
-    let mut updater = new_updater(&received_errors);
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater(&received_errors, &test_cases);
     updater.update_configuration(WorkspaceConfigurationUpdate {
         lints_config: Some(vec![LintConfig {
             kind: LintKind::Ast(AstLint::DivisionByZero),
@@ -1451,7 +1486,8 @@ async fn lints_prefer_manifest_over_workspace() {
 
     let fs = Rc::new(RefCell::new(fs));
     let received_errors = RefCell::new(Vec::new());
-    let mut updater = new_updater_with_file_system(&received_errors, &fs);
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater_with_file_system(&received_errors, &test_cases, &fs);
     updater.update_configuration(WorkspaceConfigurationUpdate {
         lints_config: Some(vec![LintConfig {
             kind: LintKind::Ast(AstLint::DivisionByZero),
@@ -1488,9 +1524,10 @@ async fn missing_dependency_reported() {
 
     let fs = Rc::new(RefCell::new(fs));
     let received_errors = RefCell::new(Vec::new());
-    let mut updater = new_updater_with_file_system(&received_errors, &fs);
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater_with_file_system(&received_errors, &test_cases, &fs);
 
-    // Triger a document update.
+    // Trigger a document update.
     updater
         .update_document("parent/src/main.qs", 1, "function Main() : Unit {}")
         .await;
@@ -1534,9 +1571,10 @@ async fn error_from_dependency_reported() {
 
     let fs = Rc::new(RefCell::new(fs));
     let received_errors = RefCell::new(Vec::new());
-    let mut updater = new_updater_with_file_system(&received_errors, &fs);
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater_with_file_system(&received_errors, &test_cases, &fs);
 
-    // Triger a document update.
+    // Trigger a document update.
     updater
         .update_document("parent/src/main.qs", 1, "function Main() : Unit {}")
         .await;
@@ -1553,6 +1591,419 @@ async fn error_from_dependency_reported() {
     );
 }
 
+#[tokio::test]
+async fn test_case_detected() {
+    let fs = FsNode::Dir(
+        [dir(
+            "parent",
+            [
+                file("qsharp.json", r#"{}"#),
+                dir("src", [file("main.qs", "function MyTestCase() : Unit {}")]),
+            ],
+        )]
+        .into_iter()
+        .collect(),
+    );
+
+    let fs = Rc::new(RefCell::new(fs));
+    let received_errors = RefCell::new(Vec::new());
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater_with_file_system(&received_errors, &test_cases, &fs);
+
+    // Trigger a document update.
+    updater
+        .update_document(
+            "parent/src/main.qs",
+            1,
+            "@Test() function MyTestCase() : Unit {}",
+        )
+        .await;
+
+    expect![[r#"
+        [
+            TestCallables {
+                callables: [
+                    TestCallable {
+                        callable_name: "main.MyTestCase",
+                        compilation_uri: "parent/qsharp.json",
+                        location: Location {
+                            source: "parent/src/main.qs",
+                            range: Range {
+                                start: Position {
+                                    line: 0,
+                                    column: 17,
+                                },
+                                end: Position {
+                                    line: 0,
+                                    column: 27,
+                                },
+                            },
+                        },
+                        friendly_name: "parent",
+                    },
+                ],
+            },
+        ]
+    "#]]
+    .assert_debug_eq(&test_cases.borrow());
+}
+
+#[tokio::test]
+async fn test_case_removed() {
+    let fs = FsNode::Dir(
+        [dir(
+            "parent",
+            [
+                file("qsharp.json", r#"{}"#),
+                dir(
+                    "src",
+                    [file("main.qs", "@Test() function MyTestCase() : Unit {}")],
+                ),
+            ],
+        )]
+        .into_iter()
+        .collect(),
+    );
+
+    let fs = Rc::new(RefCell::new(fs));
+    let received_errors = RefCell::new(Vec::new());
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater_with_file_system(&received_errors, &test_cases, &fs);
+
+    // Trigger a document update.
+    updater
+        .update_document("parent/src/main.qs", 1, "function MyTestCase() : Unit {}")
+        .await;
+
+    expect![[r#"
+        [
+            TestCallables {
+                callables: [],
+            },
+        ]
+    "#]]
+    .assert_debug_eq(&test_cases.borrow());
+}
+
+#[tokio::test]
+async fn test_case_modified() {
+    let fs = FsNode::Dir(
+        [dir(
+            "parent",
+            [
+                file("qsharp.json", r#"{}"#),
+                dir(
+                    "src",
+                    [file("main.qs", "@Test() function MyTestCase() : Unit {}")],
+                ),
+            ],
+        )]
+        .into_iter()
+        .collect(),
+    );
+
+    let fs = Rc::new(RefCell::new(fs));
+    let received_errors = RefCell::new(Vec::new());
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater_with_file_system(&received_errors, &test_cases, &fs);
+
+    // Trigger a document update.
+    updater
+        .update_document(
+            "parent/src/main.qs",
+            1,
+            "@Test() function MyTestCase() : Unit {}",
+        )
+        .await;
+
+    updater
+        .update_document(
+            "parent/src/main.qs",
+            2,
+            "@Test() function MyTestCase2() : Unit { }",
+        )
+        .await;
+
+    expect![[r#"
+        [
+            TestCallables {
+                callables: [
+                    TestCallable {
+                        callable_name: "main.MyTestCase",
+                        compilation_uri: "parent/qsharp.json",
+                        location: Location {
+                            source: "parent/src/main.qs",
+                            range: Range {
+                                start: Position {
+                                    line: 0,
+                                    column: 17,
+                                },
+                                end: Position {
+                                    line: 0,
+                                    column: 27,
+                                },
+                            },
+                        },
+                        friendly_name: "parent",
+                    },
+                ],
+            },
+            TestCallables {
+                callables: [
+                    TestCallable {
+                        callable_name: "main.MyTestCase2",
+                        compilation_uri: "parent/qsharp.json",
+                        location: Location {
+                            source: "parent/src/main.qs",
+                            range: Range {
+                                start: Position {
+                                    line: 0,
+                                    column: 17,
+                                },
+                                end: Position {
+                                    line: 0,
+                                    column: 28,
+                                },
+                            },
+                        },
+                        friendly_name: "parent",
+                    },
+                ],
+            },
+        ]
+    "#]]
+    .assert_debug_eq(&test_cases.borrow());
+}
+
+#[tokio::test]
+async fn test_annotation_removed() {
+    let fs = FsNode::Dir(
+        [dir(
+            "parent",
+            [
+                file("qsharp.json", r#"{}"#),
+                dir(
+                    "src",
+                    [file("main.qs", "@Test() function MyTestCase() : Unit {}")],
+                ),
+            ],
+        )]
+        .into_iter()
+        .collect(),
+    );
+
+    let fs = Rc::new(RefCell::new(fs));
+    let received_errors = RefCell::new(Vec::new());
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater_with_file_system(&received_errors, &test_cases, &fs);
+
+    // Trigger a document update.
+    updater
+        .update_document(
+            "parent/src/main.qs",
+            1,
+            "@Test() function MyTestCase() : Unit {}",
+        )
+        .await;
+
+    updater
+        .update_document("parent/src/main.qs", 2, "function MyTestCase() : Unit {}")
+        .await;
+
+    expect![[r#"
+        [
+            TestCallables {
+                callables: [
+                    TestCallable {
+                        callable_name: "main.MyTestCase",
+                        compilation_uri: "parent/qsharp.json",
+                        location: Location {
+                            source: "parent/src/main.qs",
+                            range: Range {
+                                start: Position {
+                                    line: 0,
+                                    column: 17,
+                                },
+                                end: Position {
+                                    line: 0,
+                                    column: 27,
+                                },
+                            },
+                        },
+                        friendly_name: "parent",
+                    },
+                ],
+            },
+            TestCallables {
+                callables: [],
+            },
+        ]
+    "#]]
+    .assert_debug_eq(&test_cases.borrow());
+}
+
+#[tokio::test]
+async fn multiple_tests() {
+    let fs = FsNode::Dir(
+        [dir(
+            "parent",
+            [
+                file("qsharp.json", r#"{}"#),
+                dir(
+                    "src",
+                    [file(
+                        "main.qs",
+                        "@Test() function Test1() : Unit {} @Test() function Test2() : Unit {}",
+                    )],
+                ),
+            ],
+        )]
+        .into_iter()
+        .collect(),
+    );
+
+    let fs = Rc::new(RefCell::new(fs));
+    let received_errors = RefCell::new(Vec::new());
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater_with_file_system(&received_errors, &test_cases, &fs);
+
+    // Trigger a document update.
+    updater
+        .update_document(
+            "parent/src/main.qs",
+            1,
+            "@Test() function Test1() : Unit {} @Test() function Test2() : Unit {}",
+        )
+        .await;
+
+    expect![[r#"
+        [
+            TestCallables {
+                callables: [
+                    TestCallable {
+                        callable_name: "main.Test1",
+                        compilation_uri: "parent/qsharp.json",
+                        location: Location {
+                            source: "parent/src/main.qs",
+                            range: Range {
+                                start: Position {
+                                    line: 0,
+                                    column: 17,
+                                },
+                                end: Position {
+                                    line: 0,
+                                    column: 22,
+                                },
+                            },
+                        },
+                        friendly_name: "parent",
+                    },
+                    TestCallable {
+                        callable_name: "main.Test2",
+                        compilation_uri: "parent/qsharp.json",
+                        location: Location {
+                            source: "parent/src/main.qs",
+                            range: Range {
+                                start: Position {
+                                    line: 0,
+                                    column: 52,
+                                },
+                                end: Position {
+                                    line: 0,
+                                    column: 57,
+                                },
+                            },
+                        },
+                        friendly_name: "parent",
+                    },
+                ],
+            },
+        ]
+    "#]]
+    .assert_debug_eq(&test_cases.borrow());
+}
+
+#[tokio::test]
+async fn test_case_in_different_files() {
+    let fs = FsNode::Dir(
+        [dir(
+            "parent",
+            [
+                file("qsharp.json", r#"{}"#),
+                dir(
+                    "src",
+                    [
+                        file("test1.qs", "@Test() function Test1() : Unit {}"),
+                        file("test2.qs", "@Test() function Test2() : Unit {}"),
+                    ],
+                ),
+            ],
+        )]
+        .into_iter()
+        .collect(),
+    );
+
+    let fs = Rc::new(RefCell::new(fs));
+    let received_errors = RefCell::new(Vec::new());
+    let test_cases = RefCell::new(Vec::new());
+    let mut updater = new_updater_with_file_system(&received_errors, &test_cases, &fs);
+
+    // Trigger a document update for the first test file.
+    updater
+        .update_document(
+            "parent/src/test1.qs",
+            1,
+            "@Test() function Test1() : Unit {}",
+        )
+        .await;
+
+    expect![[r#"
+        [
+            TestCallables {
+                callables: [
+                    TestCallable {
+                        callable_name: "test1.Test1",
+                        compilation_uri: "parent/qsharp.json",
+                        location: Location {
+                            source: "parent/src/test1.qs",
+                            range: Range {
+                                start: Position {
+                                    line: 0,
+                                    column: 17,
+                                },
+                                end: Position {
+                                    line: 0,
+                                    column: 22,
+                                },
+                            },
+                        },
+                        friendly_name: "parent",
+                    },
+                    TestCallable {
+                        callable_name: "test2.Test2",
+                        compilation_uri: "parent/qsharp.json",
+                        location: Location {
+                            source: "parent/src/test2.qs",
+                            range: Range {
+                                start: Position {
+                                    line: 0,
+                                    column: 17,
+                                },
+                                end: Position {
+                                    line: 0,
+                                    column: 22,
+                                },
+                            },
+                        },
+                        friendly_name: "parent",
+                    },
+                ],
+            },
+        ]
+    "#]]
+    .assert_debug_eq(&test_cases.borrow());
+}
+
 impl Display for DiagnosticUpdate {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         let DiagnosticUpdate {
@@ -1590,23 +2041,34 @@ impl Display for DiagnosticUpdate {
     }
 }
 
-fn new_updater(received_errors: &RefCell<Vec<DiagnosticUpdate>>) -> CompilationStateUpdater<'_> {
+fn new_updater<'a>(
+    received_errors: &'a RefCell<Vec<DiagnosticUpdate>>,
+    received_test_cases: &'a RefCell<Vec<TestCallables>>,
+) -> CompilationStateUpdater<'a> {
     let diagnostic_receiver = move |update: DiagnosticUpdate| {
         let mut v = received_errors.borrow_mut();
         v.push(update);
     };
 
+    let test_callable_receiver = move |update: TestCallables| {
+        let mut v = received_test_cases.borrow_mut();
+        v.push(update);
+    };
+
     CompilationStateUpdater::new(
         Rc::new(RefCell::new(CompilationState::default())),
         diagnostic_receiver,
+        test_callable_receiver,
         TestProjectHost {
             fs: TEST_FS.with(Clone::clone),
         },
+        Encoding::Utf8,
     )
 }
 
 fn new_updater_with_file_system<'a>(
     received_errors: &'a RefCell<Vec<DiagnosticUpdate>>,
+    received_test_cases: &'a RefCell<Vec<TestCallables>>,
     fs: &Rc<RefCell<FsNode>>,
 ) -> CompilationStateUpdater<'a> {
     let diagnostic_receiver = move |update: DiagnosticUpdate| {
@@ -1614,10 +2076,17 @@ fn new_updater_with_file_system<'a>(
         v.push(update);
     };
 
+    let test_callable_receiver = move |update: TestCallables| {
+        let mut v = received_test_cases.borrow_mut();
+        v.push(update);
+    };
+
     CompilationStateUpdater::new(
         Rc::new(RefCell::new(CompilationState::default())),
         diagnostic_receiver,
+        test_callable_receiver,
         TestProjectHost { fs: fs.clone() },
+        Encoding::Utf8,
     )
 }
 
diff --git a/language_service/src/test_utils.rs b/language_service/src/test_utils.rs
index 5667e455..01c9fbf7 100644
--- a/language_service/src/test_utils.rs
+++ b/language_service/src/test_utils.rs
@@ -223,6 +223,7 @@ fn compile_project_with_markers_cursor_optional(
         LanguageFeatures::default(),
     );
 
+    let test_cases = unit.package.get_test_callables();
     let package_id = package_store.insert(unit);
 
     (
@@ -231,10 +232,12 @@ fn compile_project_with_markers_cursor_optional(
             user_package_id: package_id,
             kind: CompilationKind::OpenProject {
                 package_graph_sources,
+                friendly_name: Arc::from("test project"),
             },
             compile_errors: errors,
             project_errors: Vec::new(),
             dependencies: dependencies.into_iter().collect(),
+            test_cases,
         },
         cursor_location,
         target_spans,
@@ -294,6 +297,7 @@ where
         kind: CompilationKind::Notebook { project: None },
         project_errors: Vec::new(),
         dependencies: [(source_package_id, None)].into_iter().collect(),
+        test_cases: Default::default(),
     }
 }
 
diff --git a/language_service/src/tests.rs b/language_service/src/tests.rs
index 645c1467..66808bd3 100644
--- a/language_service/src/tests.rs
+++ b/language_service/src/tests.rs
@@ -2,7 +2,7 @@
 // Licensed under the MIT License.
 
 use crate::{
-    protocol::{DiagnosticUpdate, ErrorKind},
+    protocol::{DiagnosticUpdate, ErrorKind, TestCallables},
     Encoding, LanguageService, UpdateWorker,
 };
 use expect_test::{expect, Expect};
@@ -15,8 +15,9 @@ pub(crate) mod test_fs;
 #[tokio::test]
 async fn single_document() {
     let received_errors = RefCell::new(Vec::new());
+    let test_cases = RefCell::new(Vec::new());
     let mut ls = LanguageService::new(Encoding::Utf8);
-    let mut worker = create_update_worker(&mut ls, &received_errors);
+    let mut worker = create_update_worker(&mut ls, &received_errors, &test_cases);
 
     ls.update_document("foo.qs", 1, "namespace Foo { }");
 
@@ -49,8 +50,9 @@ async fn single_document() {
 #[allow(clippy::too_many_lines)]
 async fn single_document_update() {
     let received_errors = RefCell::new(Vec::new());
+    let test_cases = RefCell::new(Vec::new());
     let mut ls = LanguageService::new(Encoding::Utf8);
-    let mut worker = create_update_worker(&mut ls, &received_errors);
+    let mut worker = create_update_worker(&mut ls, &received_errors, &test_cases);
 
     ls.update_document("foo.qs", 1, "namespace Foo { }");
 
@@ -114,8 +116,9 @@ async fn single_document_update() {
 #[allow(clippy::too_many_lines)]
 async fn document_in_project() {
     let received_errors = RefCell::new(Vec::new());
+    let test_cases = RefCell::new(Vec::new());
     let mut ls = LanguageService::new(Encoding::Utf8);
-    let mut worker = create_update_worker(&mut ls, &received_errors);
+    let mut worker = create_update_worker(&mut ls, &received_errors, &test_cases);
 
     ls.update_document("project/src/this_file.qs", 1, "namespace Foo { }");
 
@@ -167,8 +170,9 @@ async fn document_in_project() {
 #[tokio::test]
 async fn completions_requested_before_document_load() {
     let errors = RefCell::new(Vec::new());
+    let test_cases = RefCell::new(Vec::new());
     let mut ls = LanguageService::new(Encoding::Utf8);
-    let _worker = create_update_worker(&mut ls, &errors);
+    let _worker = create_update_worker(&mut ls, &errors, &test_cases);
 
     ls.update_document(
         "foo.qs",
@@ -195,8 +199,9 @@ async fn completions_requested_before_document_load() {
 #[tokio::test]
 async fn completions_requested_after_document_load() {
     let errors = RefCell::new(Vec::new());
+    let test_cases = RefCell::new(Vec::new());
     let mut ls = LanguageService::new(Encoding::Utf8);
-    let mut worker = create_update_worker(&mut ls, &errors);
+    let mut worker = create_update_worker(&mut ls, &errors, &test_cases);
 
     // this test is a contrast to `completions_requested_before_document_load`
     // we want to ensure that completions load when the update_document call has been awaited
@@ -264,6 +269,7 @@ type ErrorInfo = (
 fn create_update_worker<'a>(
     ls: &mut LanguageService,
     received_errors: &'a RefCell<Vec<ErrorInfo>>,
+    received_test_cases: &'a RefCell<Vec<TestCallables>>,
 ) -> UpdateWorker<'a> {
     let worker = ls.create_update_worker(
         |update: DiagnosticUpdate| {
@@ -285,6 +291,10 @@ fn create_update_worker<'a>(
                 project_errors.collect(),
             ));
         },
+        move |update: TestCallables| {
+            let mut v = received_test_cases.borrow_mut();
+            v.push(update);
+        },
         TestProjectHost {
             fs: TEST_FS.with(Clone::clone),
         },
diff --git a/library/fixed_point/src/Tests.qs b/library/fixed_point/src/Tests.qs
index 24826d07..29568bea 100644
--- a/library/fixed_point/src/Tests.qs
+++ b/library/fixed_point/src/Tests.qs
@@ -9,11 +9,7 @@ import Std.Convert.IntAsDouble;
 import Std.Math.AbsD;
 import Operations.*;
 
-operation Main() : Unit {
-    FxpMeasurementTest();
-    FxpOperationTests();
-}
-
+@Test()
 operation FxpMeasurementTest() : Unit {
     for numQubits in 3..12 {
         for numIntBits in 2..numQubits {
@@ -43,6 +39,7 @@ operation TestConstantMeasurement(constant : Double, registerWidth : Int, intege
     ResetAll(register);
 }
 
+@Test()
 operation FxpOperationTests() : Unit {
     for i in 0..10 {
         let constant1 = 0.2 * IntAsDouble(i);
@@ -54,6 +51,7 @@ operation FxpOperationTests() : Unit {
         TestSquare(constant1);
     }
 }
+
 operation TestSquare(a : Double) : Unit {
     Message($"Testing Square({a})");
     use resultRegister = Qubit[30];
diff --git a/library/qtest/src/Tests.qs b/library/qtest/src/Tests.qs
index ab8b9e14..50a3b4c1 100644
--- a/library/qtest/src/Tests.qs
+++ b/library/qtest/src/Tests.qs
@@ -46,24 +46,33 @@ function FunctionTestMatrixTests() : Unit {
     Fact(All(result -> result.did_pass, results), "test matrix did not pass all test cases");
 }
 
-function BasicTests() : Unit {
-    let sample_tests = [
+function BasicTests() : (String, () -> Int, Int)[] {
+    return [
         ("Should return 42", TestCaseOne, 43),
         ("Should add one", () -> AddOne(5), 42),
         ("Should add one", () -> AddOne(5), 6)
     ];
+}
 
+@Test()
+function ReturnsFalseForFailingTest() : Unit {
     Fact(
-        not Functions.CheckAllTestCases(sample_tests),
+        not Functions.CheckAllTestCases(BasicTests()),
         "Test harness failed to return false for a failing tests."
     );
+}
 
+@Test()
+function ReturnsTrueForPassingTest() : Unit {
     Fact(
         Functions.CheckAllTestCases([("always returns true", () -> true, true)]),
         "Test harness failed to return true for a passing test"
     );
+}
 
-    let run_all_result = Functions.RunAllTestCases(sample_tests);
+@Test()
+function RunAllTests() : Unit {
+    let run_all_result = Functions.RunAllTestCases(BasicTests());
 
     Fact(
         Length(run_all_result) == 3,
diff --git a/library/rotations/src/Tests.qs b/library/rotations/src/Tests.qs
index 0d9267e5..8bb9a2f7 100644
--- a/library/rotations/src/Tests.qs
+++ b/library/rotations/src/Tests.qs
@@ -6,11 +6,7 @@ import Std.Math.HammingWeightI, Std.Math.PI;
 
 import HammingWeightPhasing.HammingWeightPhasing, HammingWeightPhasing.WithHammingWeight;
 
-operation Main() : Unit {
-    TestHammingWeight();
-    TestPhasing();
-}
-
+@Test()
 operation TestHammingWeight() : Unit {
     // exhaustive
     use qs = Qubit[4];
@@ -41,6 +37,7 @@ operation TestHammingWeight() : Unit {
     }
 }
 
+@Test()
 operation TestPhasing() : Unit {
     for theta in [1.0, 2.0, 0.0, -0.5, 5.0 * PI()] {
         for numQubits in 1..6 {
diff --git a/library/signed/src/Tests.qs b/library/signed/src/Tests.qs
index b8afe374..3fbe29d3 100644
--- a/library/signed/src/Tests.qs
+++ b/library/signed/src/Tests.qs
@@ -5,17 +5,10 @@ import Std.Diagnostics.Fact;
 import Operations.Invert2sSI;
 import Measurement.MeasureSignedInteger;
 
-/// This entrypoint runs tests for the signed integer library.
-operation Main() : Unit {
-    UnsignedOpTests();
-    Fact(Qtest.Operations.CheckAllTestCases(MeasureSignedIntTests()), "SignedInt tests failed");
-    SignedOpTests();
-
-}
-
-function MeasureSignedIntTests() : (String, Int, (Qubit[]) => (), (Qubit[]) => Int, Int)[] {
-    [
-        ("0b0001 == 1", 4, (qs) => X(qs[0]), (qs) => MeasureSignedInteger(qs, 4), 1),
+@Test()
+operation MeasureSignedIntTests() : Unit {
+    let testCases = [
+        ("0b0001 == 1", 4, (qs) => X(qs[0]), (qs) => MeasureSignedInteger(qs, 6), 1),
         ("0b1111 == -1", 4, (qs) => { X(qs[0]); X(qs[1]); X(qs[2]); X(qs[3]); }, (qs) => MeasureSignedInteger(qs, 4), -1),
         ("0b01000 == 8", 5, (qs) => X(qs[3]), (qs) => MeasureSignedInteger(qs, 5), 8),
         ("0b11110 == -2", 5, (qs) => {
@@ -25,9 +18,11 @@ function MeasureSignedIntTests() : (String, Int, (Qubit[]) => (), (Qubit[]) => I
             X(qs[4]);
         }, (qs) => MeasureSignedInteger(qs, 5), -2),
         ("0b11000 == -8", 5, (qs) => { X(qs[3]); X(qs[4]); }, (qs) => MeasureSignedInteger(qs, 5), -8)
-    ]
+    ];
+    Fact(Qtest.Operations.CheckAllTestCases(testCases), "SignedInt tests failed");
 }
 
+@Test()
 operation SignedOpTests() : Unit {
     use a = Qubit[32];
     use b = Qubit[32];
@@ -54,6 +49,7 @@ operation SignedOpTests() : Unit {
 
 }
 
+@Test()
 operation UnsignedOpTests() : Unit {
     use a = Qubit[2];
     use b = Qubit[2];
diff --git a/npm/qsharp/test/basics.js b/npm/qsharp/test/basics.js
index eae27214..abb787d9 100644
--- a/npm/qsharp/test/basics.js
+++ b/npm/qsharp/test/basics.js
@@ -504,6 +504,80 @@ test("language service diagnostics", async () => {
   assert(gotDiagnostics);
 });
 
+test("test callable discovery", async () => {
+  const languageService = getLanguageService();
+  let gotTests = false;
+  languageService.addEventListener("testCallables", (event) => {
+    gotTests = true;
+    assert.equal(event.type, "testCallables");
+    assert.equal(event.detail.callables.length, 1);
+    assert.equal(event.detail.callables[0].callableName, "Sample.main");
+    assert.deepStrictEqual(event.detail.callables[0].location, {
+      source: "test.qs",
+      span: {
+        end: {
+          character: 18,
+          line: 2,
+        },
+        start: {
+          character: 14,
+          line: 2,
+        },
+      },
+    });
+  });
+  await languageService.updateDocument(
+    "test.qs",
+    1,
+    `namespace Sample {
+    @Test()
+    operation main() : Unit {}
+}`,
+  );
+
+  // dispose() will complete when the language service has processed all the updates.
+  await languageService.dispose();
+  assert(gotTests);
+});
+
+test("multiple test callable discovery", async () => {
+  const languageService = getLanguageService();
+  let gotTests = false;
+  languageService.addEventListener("testCallables", (event) => {
+    gotTests = true;
+    assert.equal(event.type, "testCallables");
+    assert.equal(event.detail.callables.length, 4);
+    assert.equal(event.detail.callables[0].callableName, "Sample.test1");
+    assert.equal(event.detail.callables[1].callableName, "Sample.test2");
+    assert.equal(event.detail.callables[2].callableName, "Sample2.test1");
+    assert.equal(event.detail.callables[3].callableName, "Sample2.test2");
+  });
+  await languageService.updateDocument(
+    "test.qs",
+    1,
+    `namespace Sample {
+    @Test()
+    operation test1() : Unit {}
+
+    @Test()
+    function test2() : Unit {}
+}
+namespace Sample2 {
+    @Test()
+    operation test1() : Unit {}
+
+    @Test()
+    function test2() : Unit {}
+  }    
+}
+`,
+  );
+
+  // dispose() will complete when the language service has processed all the updates.
+  await languageService.dispose();
+  assert(gotTests);
+});
+
 test("diagnostics with related spans", async () => {
   const languageService = getLanguageService();
   let gotDiagnostics = false;
diff --git a/samples/language/TestAttribute.qs b/samples/language/TestAttribute.qs
new file mode 100644
index 00000000..95f4e737
--- /dev/null
+++ b/samples/language/TestAttribute.qs
@@ -0,0 +1,21 @@
+// # Sample
+// Test Attribute
+//
+// # Description
+// A Q# function or operation (callable) can be designated as a test case via the @Test() attribute.
+// In VS Code, these tests will show up in the "test explorer" in the Activity Bar.
+// If the test crashes, it is a failure. If it runs to completion, it is a success.
+
+// Tests must take zero parameters, and contain no generic types (type parameters).
+@Test()
+function TestPass() : Unit {
+    Std.Diagnostics.Fact(true, "This test should pass.");
+}
+
+// Because this function asserts `false`, it will crash and the test will fail.
+@Test()
+function TestFail() : Unit {
+    Std.Diagnostics.Fact(false, "This test should fail.");
+}
+
+function Main() : Unit {}
\ No newline at end of file
diff --git a/samples_test/src/tests/language.rs b/samples_test/src/tests/language.rs
index f2362a47..4cd55b69 100644
--- a/samples_test/src/tests/language.rs
+++ b/samples_test/src/tests/language.rs
@@ -362,3 +362,6 @@ pub const CLASSCONSTRAINTS_EXPECT_DEBUG: Expect = expect![[r#"
     false
     true
     ()"#]];
+
+pub const TESTATTRIBUTE_EXPECT: Expect = expect!["()"];
+pub const TESTATTRIBUTE_EXPECT_DEBUG: Expect = expect!["()"];
diff --git a/vscode/src/language-service/testExplorer.ts b/vscode/src/language-service/testExplorer.ts
new file mode 100644
index 00000000..b6204d0a
--- /dev/null
+++ b/vscode/src/language-service/testExplorer.ts
@@ -0,0 +1,242 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import {
+  ICompilerWorker,
+  ILanguageService,
+  ITestDescriptor,
+  log,
+} from "qsharp-lang";
+import * as vscode from "vscode";
+import { loadCompilerWorker, toVsCodeLocation, toVsCodeRange } from "../common";
+import { getProgramForDocument } from "../programConfig";
+import { createDebugConsoleEventTarget } from "../debugger/output";
+
+let worker: ICompilerWorker | null = null;
+/**
+ * Returns a singleton instance of the compiler worker.
+ * @param context The extension context.
+ * @returns The compiler worker.
+ **/
+function getLocalCompilerWorker(extensionUri: vscode.Uri): ICompilerWorker {
+  if (worker !== null) {
+    return worker;
+  }
+
+  worker = loadCompilerWorker(extensionUri);
+
+  return worker;
+}
+
+export function startTestDiscovery(
+  languageService: ILanguageService,
+  context: vscode.ExtensionContext,
+): vscode.Disposable[] {
+  // test explorer features
+  const testController: vscode.TestController =
+    vscode.tests.createTestController("qsharpTestController", "Q# Tests");
+  const runHandler = (request: vscode.TestRunRequest) => {
+    if (!request.continuous) {
+      return startTestRun(request);
+    }
+  };
+
+  // runs an individual test run
+  // or test group (a test run where there are child tests)
+  const startTestRun = async (request: vscode.TestRunRequest) => {
+    // use the compiler worker to run the test in the interpreter
+
+    log.trace("Starting test run, request was", JSON.stringify(request));
+
+    const worker = getLocalCompilerWorker(context.extensionUri);
+
+    // request.include is an array of test cases to run, and it is only provided if a specific set of tests were selected.
+    if (request.include !== undefined) {
+      for (const testCase of request.include || []) {
+        await runTestCase(testController, testCase, request, worker);
+      }
+    } else {
+      // alternatively, if there is no include specified, we run all tests that are not in the exclude list
+      for (const [, testCase] of testController.items) {
+        if (request.exclude && request.exclude.includes(testCase)) {
+          continue;
+        }
+        await runTestCase(testController, testCase, request, worker);
+      }
+    }
+  };
+
+  /**
+   * Given a single test case, run it in the worker (which runs the interpreter) and report results back to the
+   * `TestController` as a side effect.
+   *
+   * This function manages its own event target for the results of the test run and uses the controller to render the output in the VS Code UI.
+   **/
+  async function runTestCase(
+    ctrl: vscode.TestController,
+    testCase: vscode.TestItem,
+    request: vscode.TestRunRequest,
+    worker: ICompilerWorker,
+  ): Promise<void> {
+    log.trace("Running Q# test: ", testCase.id);
+    if (testCase.children.size > 0) {
+      for (const childTestCase of testCase.children) {
+        await runTestCase(ctrl, childTestCase[1], request, worker);
+      }
+      return;
+    }
+    const run = ctrl.createTestRun(request);
+    const evtTarget = createDebugConsoleEventTarget((msg) => {
+      run.appendOutput(`${msg}\n`);
+    });
+    evtTarget.addEventListener("Result", (msg) => {
+      if (msg.detail.success) {
+        run.passed(testCase);
+      } else {
+        const failureLocation =
+          msg.detail?.value?.uri ||
+          (msg.detail?.value?.related &&
+            msg.detail.value.related[0].location?.source) ||
+          null;
+
+        const message: vscode.TestMessage = {
+          message: msg.detail.value.message,
+          location:
+            failureLocation === null
+              ? undefined
+              : {
+                  range: toVsCodeRange(msg.detail.value.range),
+                  uri: vscode.Uri.parse(failureLocation),
+                },
+        };
+        run.failed(testCase, message);
+      }
+      run.end();
+    });
+
+    const callableExpr = `${testCase.id}()`;
+    const uri = testCase.uri;
+    if (!uri) {
+      log.error(`No compilation URI for test ${testCase.id}`);
+      run.appendOutput(`No compilation URI for test ${testCase.id}\r\n`);
+      return;
+    }
+    const programResult = await getProgramForDocument(uri);
+
+    if (!programResult.success) {
+      throw new Error(programResult.errorMsg);
+    }
+
+    const program = programResult.programConfig;
+
+    try {
+      await worker.run(program, callableExpr, 1, evtTarget);
+    } catch (error) {
+      log.error(`Error running test ${testCase.id}:`, error);
+      run.appendOutput(`Error running test ${testCase.id}: ${error}\r\n`);
+    }
+    log.trace("ran test:", testCase.id);
+  }
+
+  testController.createRunProfile(
+    "Interpreter",
+    vscode.TestRunProfileKind.Run,
+    runHandler,
+    true,
+    undefined,
+    false,
+  );
+
+  const testVersions = new WeakMap<vscode.TestItem, number>();
+  async function onTestCallables(evt: {
+    detail: {
+      callables: ITestDescriptor[];
+    };
+  }) {
+    let currentVersion = 0;
+    for (const [, testItem] of testController.items) {
+      currentVersion = (testVersions.get(testItem) || 0) + 1;
+      break;
+    }
+
+    for (const { callableName, location, friendlyName } of evt.detail
+      .callables) {
+      const vscLocation = toVsCodeLocation(location);
+      // below, we transform `parts` into a tree structure for the test explorer
+      // e.g. if we have the following callables:
+      // - "TestSuite.Test1"
+      // - "TestSuite.Test2"
+      // they will be turned into the parts:
+      // - ["FriendlyName", "TestSuite", "Test1"]
+      // - ["FriendlyName", "TestSuite", "Test2"]
+      // and then into a tree structure:
+      // - FriendlyName
+      //   - TestSuite
+      //     - Test1
+      //     - Test2
+      const parts = [friendlyName, ...callableName.split(".")];
+
+      let rover = testController.items;
+      for (let i = 0; i < parts.length; i++) {
+        const part = parts[i];
+        // the `id` is used to actually call the test item
+        // it is constructed in the test runner via: callableExpr = `${testCase.id}()`;
+        // so it should be the full path to the test item, not including the "friendly name" (since that isn't in the callable expr),
+        // if and only if it is a "leaf" (an actual test case)
+        // note that leaves are test cases and internal nodes are not test cases
+        // in teh above example, TestSuite would have the id `FriendlyName.TestSuite`, and Test1 would have the id `TestSuite.Test1`
+        const id =
+          i === parts.length - 1
+            ? callableName
+            : parts.slice(0, i + 1).join(".");
+        // this test item may have already existed from a previous scan, so fetch it
+        let testItem = rover.get(id);
+
+        // if it doesn't exist, create it
+        if (!testItem) {
+          testItem = testController.createTestItem(id, part, vscLocation.uri);
+          // if this is the actual test item, give it a range and a compilation uri
+          // this triggers the little "green play button" in the test explorer and in the left
+          // gutter of the editor
+          if (i === parts.length - 1) {
+            testItem.range = vscLocation.range;
+          }
+          rover.add(testItem);
+        }
+        testVersions.set(testItem, currentVersion);
+        rover = testItem.children;
+      }
+    }
+
+    // delete old items from previous versions that were not updated
+    deleteItemsNotOfVersion(
+      currentVersion,
+      testController.items,
+      testController,
+    );
+  }
+
+  function deleteItemsNotOfVersion(
+    version: number,
+    items: vscode.TestItemCollection,
+    testController: vscode.TestController,
+  ) {
+    for (const [id, testItem] of items) {
+      deleteItemsNotOfVersion(version, testItem.children, testController);
+      if (testVersions.get(testItem) !== version) {
+        items.delete(id);
+      }
+    }
+  }
+
+  languageService.addEventListener("testCallables", onTestCallables);
+
+  return [
+    {
+      dispose: () => {
+        languageService.removeEventListener("testCallables", onTestCallables);
+      },
+    },
+    testController,
+  ];
+}
diff --git a/wasm/src/test_discovery.rs b/wasm/src/test_discovery.rs
new file mode 100644
index 00000000..63149605
--- /dev/null
+++ b/wasm/src/test_discovery.rs
@@ -0,0 +1,24 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use serde::{Deserialize, Serialize};
+use wasm_bindgen::prelude::wasm_bindgen;
+
+use crate::serializable_type;
+
+serializable_type! {
+    TestDescriptor,
+    {
+        pub callable_name: String,
+        pub location: crate::line_column::Location,
+        pub compilation_uri: String,
+        pub friendly_name: String,
+    },
+    r#"export interface ITestDescriptor {
+        callableName: string;
+        location: ILocation;
+        compilationUri: string;
+        friendlyName: string;
+    }"#,
+    ITestDescriptor
+}